Commit 9884e87d authored by ZeinabRm13's avatar ZeinabRm13

Add migrations

parent 8d7cb029
......@@ -5,7 +5,7 @@
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
......@@ -16,7 +16,7 @@ script_location = %(here)s/alembic
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
prepend_sys_path = src
# timezone to use when rendering the date within the migration file
......@@ -84,7 +84,7 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql+asyncpg://chart_analyzer_user:chartanalyzer13@localhost:5432/chart_analyzer
sqlalchemy.url = "postgresql+psycopg://chart_analyzer_user:chartanalyzer13@localhost:5432/chart_analyzer"
[post_write_hooks]
......
# alembic/env.py
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from alembic import context
import asyncio
import sys
import os
# Add the project root to sys.path to import your models
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
if project_root not in sys.path:
sys.path.insert(0, project_root)
import os
import sys
# Import your Base from your app
from src.infrastructure.adapters.sqlserver import Base
# Add the project root directory to the Python path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Import your settings and models
from src.config import settings
from src.infrastructure.adapters.sqlserver.models import Base
# ------------------------------
# Configure Alembic
# ------------------------------
config = context.config
# Setup logging (if you have it in alembic.ini)
# Setup logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set target metadata for autogenerate
target_metadata = Base.metadata
# Set the database URL from settings
config.set_main_option('sqlalchemy.url', settings.DATABASE_URL)
url = config.get_main_option("sqlalchemy.url")
# ------------------------------
# Offline Mode (no DB connection)
# Offline Mode
# ------------------------------
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"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
......@@ -49,37 +49,27 @@ def run_migrations_offline() -> None:
# ------------------------------
# Online Mode (with async engine)
# Online Mode (with sync engine)
# ------------------------------
async def run_async_migrations() -> None:
"""Create and run async migrations."""
connectable: AsyncEngine = create_async_engine(
config.get_main_option("sqlalchemy.url"),
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,
)
async with connectable.connect() as connection:
# ✅ Correct: Configure context inside run_sync
await connection.run_sync(lambda conn: context.configure(
connection=conn,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
))
# ✅ Correct: Wrap run_migrations in lambda to avoid extra arg
await connection.run_sync(lambda conn: context.run_migrations())
await connectable.dispose()
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
with context.begin_transaction():
context.run_migrations()
# ------------------------------
# Choose mode
# Run the appropriate mode
# ------------------------------
if context.is_offline_mode():
run_migrations_offline()
......
"""Initial migration
Revision ID: b052cb31f4a8
Revises:
Create Date: 2025-07-23 08:29:32.710203
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b052cb31f4a8'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('last_login', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('chart_images',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('image_data', sa.LargeBinary(), nullable=False),
sa.Column('uploaded_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('chart_analyses',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('chart_image_id', sa.UUID(), nullable=False),
sa.Column('question', sa.Text(), nullable=False),
sa.Column('answer', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['chart_image_id'], ['chart_images.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('chart_analyses')
op.drop_table('chart_images')
op.drop_table('users')
# ### end Alembic commands ###
from fastapi import FastAPI
from src.infrastructure.api.fastapi.routes import auth, charts
app = FastAPI(
title="Chart Analyzer API",
description="API for analyzing charts and managing users.",
version="1.0.0",
)
app.include_router(auth.router, prefix="/auth")
app.include_router(charts.router, prefix="/charts")
@app.get("/")
async def root():
return {"message": "Welcome to Chart Analyzer"}
from .config import Settings
\ No newline at end of file
from authentication import (
from .authentication import (
RegisterRequestDTO,
LoginRequestDTO,
UserResponseDTO,
TokenResponseDTO
)
from analysis import AnalysisRequestDTO, AnalysisResponseDTO
\ No newline at end of file
from .analysis import AnalysisRequestDTO, AnalysisResponseDTO
\ No newline at end of file
from abc import ABC, abstractmethod
from domain.entities.user import User
from application.dtos.authentication import (
RegisterRequest,
LoginRequest,
UserResponse,
TokenResponse
from src.domain.entities.user import User
from src.application.dtos.authentication import (
RegisterRequestDTO,
LoginRequestDTO,
UserResponseDTO,
TokenResponseDTO
)
# src/domain/ports/auth_service.py
class AuthServicePort(ABC):
@abstractmethod
async def register(self, request: RegisterRequest) -> UserResponse: ...
async def register(self, email, password) -> User: ...
@abstractmethod
async def login(self, request: LoginRequest) -> TokenResponse: # Returns JWT
...
\ No newline at end of file
async def login(self, email, password) -> str: ...
\ No newline at end of file
......@@ -2,15 +2,15 @@
from datetime import datetime, timezone, timedelta
from jose import jwt
from passlib.context import CryptContext
from domain.ports import UserRepositoryPort
from domain.entities.user import User
from src.domain.ports.repositories.user_repository import UserRepositoryPort
from src.domain.entities.user import User
import uuid
from application.ports import AuthServicePort
from application.dtos.authentication import (
RegisterRequest,
LoginRequest,
UserResponse,
TokenResponse
from src.application.ports.authentication_service_port import AuthServicePort
from src.application.dtos.authentication import (
RegisterRequestDTO,
LoginRequestDTO,
UserResponseDTO,
TokenResponseDTO
)
class AuthService(AuthServicePort):
......@@ -27,27 +27,24 @@ class AuthService(AuthServicePort):
self._expires_minutes = expires_minutes
self._pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
async def register(self, request: RegisterRequest) -> UserResponse:
if await self._user_repo.get_by_email(request.email):
async def register(self, email, password) -> User:
if await self._user_repo.get_by_email(email):
raise ValueError("Email already registered")
user = User(
id=str(uuid.uuid4()),
email=request.email,
password_hash=self._hash_password(request.password)
email=email,
password_hash=self._hash_password(password)
)
await self._user_repo.create_user(user)
return self._user_to_dto(user)
return user
async def login(self, request: LoginRequest) -> TokenResponse:
user = await self._user_repo.get_by_email(request.email)
if not user or not self._verify_password(request.password, user.password_hash):
async def login(self, email, password) -> str:
user = await self._user_repo.get_by_email(email)
if not user or not self._verify_password(password, user.password_hash):
raise ValueError("Invalid credentials")
return TokenResponse(
access_token=self._create_access_token(user.email),
token_type="bearer"
)
return self._create_access_token(user.email)
def _hash_password(self, password: str) -> str:
return self._pwd_context.hash(password)
......@@ -63,8 +60,8 @@ class AuthService(AuthServicePort):
algorithm=self._algorithm
)
def _user_to_dto(self, user: User) -> UserResponse:
return UserResponse(
def _user_to_dto(self, user: User) -> UserResponseDTO:
return UserResponseDTO(
id=user.id,
email=user.email,
is_active=user.is_active
......
from domain.entities import ChartImage
from domain.ports import ChartsRepositoryPort
from src.domain.entities.chart_image import ChartImage
from src.domain.ports.repositories.charts_repository import ChartsRepositoryPort
import uuid
class UploadChartUseCase:
......@@ -12,4 +12,5 @@ class UploadChartUseCase:
user_id=user_id,
image_data=image_bytes
)
return self._image_repo.save(chart_image) # Returns image ID
\ No newline at end of file
self._image_repo.save(chart_image)
return chart_image.id
\ No newline at end of file
# src/config.py
from pydantic import ConfigDict
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://chart_analyzer_user:chartanalyzer13@localhost:5432/chart_analyzer"
DATABASE_URL: str = "postgresql+psycopg://chart_analyzer_user:chartanalyzer13@localhost:5432/chart_analyzer"
JWT_SECRET: str = "a_very_secret_key"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 30
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
# Use model_config instead of inner Config class
model_config = ConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore" # or "allow" if you want to allow extra fields
)
settings = Settings()
\ No newline at end of file
......@@ -2,10 +2,13 @@ from fastapi import Depends
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from application.services import AuthService
from domain.ports import UserRepositoryPort
from infrastructure.adapters.sqlserver.sql_user_repository import SqlUserRepository
from config import settings
from src.application.services.authentication_service import AuthService
from src.application.use_cases.upload_chart import UploadChartUseCase
from src.domain.ports.repositories.user_repository import UserRepositoryPort
from src.domain.ports.repositories.charts_repository import ChartsRepositoryPort
from src.infrastructure.adapters.sqlserver.sql_user_repository import SqlUserRepository
from src.infrastructure.adapters.sqlserver.sql_charts_repository import SqlChartsRepository
from src.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
......@@ -18,6 +21,9 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
def get_user_repository(session: AsyncSession = Depends(get_db_session)) -> UserRepositoryPort:
return SqlUserRepository(session)
def get_charts_repository(session: AsyncSession = Depends(get_db_session)) -> ChartsRepositoryPort:
return SqlChartsRepository(session)
def get_auth_service(
user_repo: UserRepositoryPort = Depends(get_user_repository),
) -> AuthService:
......@@ -26,4 +32,7 @@ def get_auth_service(
secret_key=settings.JWT_SECRET,
algorithm=settings.JWT_ALGORITHM,
expires_minutes=settings.JWT_EXPIRE_MINUTES,
)
\ No newline at end of file
)
def get_upload_use_case(charts_repo: ChartsRepositoryPort = Depends(get_charts_repository)) -> UploadChartUseCase:
return UploadChartUseCase(charts_repo)
\ No newline at end of file
from abc import ABC, abstractmethod
from domain.entities import ChartAnalysis
from src.domain.entities import ChartAnalysis
class AnalysisRepositoryPort(ABC):
@abstractmethod
......
from abc import ABC, abstractmethod
from domain.entities import ChartImage
from src.domain.entities.chart_image import ChartImage
class ChartsRepositoryPort(ABC):
@abstractmethod
......
from abc import ABC, abstractmethod
from domain.entities.user import User
from src.domain.entities.user import User
class UserRepositoryPort(ABC):
@abstractmethod
......
from jose import jwt
from datetime import datetime, timedelta
from src.config import settings
class JwtTokenGenerator:
def create_token(self, user_id: int) -> str:
payload = {
"sub": str(user_id),
"exp": datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_token(self, token: str) -> int:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
return int(payload["sub"])
\ No newline at end of file
# src/infrastructure/database/models.py
from sqlalchemy import Column, String, Boolean, DateTime, LargeBinary, ForeignKey, Text
from sqlalchemy.sql import func
from sqlalchemy.dialects.mssql import UNIQUEIDENTIFIER # For SQL Server UUID
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
import uuid
# 1. Create a Base class that all your models will inherit from.
Base = declarative_base()
......@@ -11,7 +12,7 @@ Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(UNIQUEIDENTIFIER, primary_key=True, server_default=func.newid())
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False) # Store only hashed passwords
is_active = Column(Boolean, default=True)
......@@ -27,8 +28,8 @@ class User(Base):
class ChartImage(Base):
__tablename__ = 'chart_images'
id = Column(UNIQUEIDENTIFIER, primary_key=True, server_default=func.newid())
user_id = Column(UNIQUEIDENTIFIER, ForeignKey('users.id'), nullable=False)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=False)
image_data = Column(LargeBinary, nullable=False) # For storing binary data
uploaded_at = Column(DateTime(timezone=True), server_default=func.now())
......@@ -41,8 +42,8 @@ class ChartImage(Base):
class ChartAnalysis(Base):
__tablename__ = 'chart_analyses'
id = Column(UNIQUEIDENTIFIER, primary_key=True, server_default=func.newid())
chart_image_id = Column(UNIQUEIDENTIFIER, ForeignKey('chart_images.id'), nullable=False)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
chart_image_id = Column(UUID(as_uuid=True), ForeignKey('chart_images.id'), nullable=False)
question = Column(Text, nullable=False)
answer = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
......
from sqlalchemy import create_engine
from domain.ports import AnalysisRepositoryPort
from domain.entities import ChartAnalysis, ChartImage
from src.domain.ports import AnalysisRepositoryPort
from src.domain.entities import ChartAnalysis, ChartImage
class SqlAnalysisRepository(AnalysisRepositoryPort):
def __init__(self, connection_string: str):
self._engine = create_engine(connection_string)
......
from sqlalchemy import create_engine
from domain.ports import ChartsRepositoryPort
from domain.entities import ChartImage
from src.domain.ports.repositories.charts_repository import ChartsRepositoryPort
from src.domain.entities.chart_image import ChartImage
from sqlalchemy.orm import Session
class SqlChartsRepository(ChartsRepositoryPort):
def __init__(self, connection_string: str):
self._engine = create_engine(connection_string)
def __init__(self, session: Session):
self._session = session
def save(self, image: ChartImage) -> str:
with self._engine.connect() as conn:
conn.execute(
"INSERT INTO Charts (Id, UserId, ImageData, UploadedAt) "
"VALUES (?, ?, ?, ?)",
(image.id, image.user_id, image.image_data, image.uploaded_at)
)
conn.commit()
return image.id # Return the ID instead of a file path
\ No newline at end of file
self._session.add(image)
self._session.commit()
return image.id
\ No newline at end of file
# src/infrastructure/adapters/sql_user_repository.py
from sqlalchemy import select
from domain.ports import UserRepositoryPort
from domain.entities.user import User
from src.domain.ports import UserRepositoryPort
from src.domain.entities.user import User
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import EmailStr
......
from fastapi import APIRouter, UploadFile, File, Depends
from application.use_cases.upload_chart import UploadChartUseCase
from fastapi import APIRouter, Depends, HTTPException, status
from application.dtos import RegisterRequestDTO, LoginRequestDTO, TokenResponseDTO
from application.ports import AuthServicePort
from dependencies import get_auth_service, get_upload_use_case
router = APIRouter(tags=["auth"])
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(
request: RegisterRequestDTO,
auth_service: AuthServicePort = Depends(get_auth_service)
):
try:
user = await auth_service.register(request.email, request.password)
return {"id": user.id, "email": user.email}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/login", response_model=TokenResponseDTO)
async def login(
request: LoginRequestDTO,
auth_service: AuthServicePort = Depends(get_auth_service)
):
try:
token = await auth_service.login(request.email, request.password)
return {"access_token": token, "token_type": "bearer"}
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e))
@router.post("/upload")
async def upload_chart(
file: UploadFile = File(...),
use_case: UploadChartUseCase = Depends(get_upload_use_case)
) -> dict:
image_bytes = await file.read()
image_id = use_case.execute(image_bytes, "user123") # Replace with real user ID
return {"image_id": image_id}
\ No newline at end of file
# src/infrastructure/api/fastapi/routes/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from application.dtos.authentication import RegisterRequest, LoginRequest, TokenResponse, UserResponse
from application.services import AuthService
from dependencies import get_auth_service
from src.application.dtos.authentication import RegisterRequestDTO, LoginRequestDTO, TokenResponseDTO
from src.application.ports.authentication_service_port import AuthServicePort
from src.dependencies import get_auth_service
router = APIRouter(tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(
request: RegisterRequest,
auth_service: AuthService = Depends(get_auth_service),
request: RegisterRequestDTO,
auth_service: AuthServicePort = Depends(get_auth_service)
):
try:
return await auth_service.register(request)
user = await auth_service.register(request.email, request.password)
return {"id": user.id, "email": user.email}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
@router.post("/login", response_model=TokenResponse)
@router.post("/login", response_model=TokenResponseDTO)
async def login(
request: LoginRequest,
auth_service: AuthService = Depends(get_auth_service),
request: LoginRequestDTO,
auth_service: AuthServicePort = Depends(get_auth_service)
):
try:
return await auth_service.login(request)
token = await auth_service.login(request.email, request.password)
return {"access_token": token, "token_type": "bearer"}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={"WWW-Authenticate": "Bearer"},
)
\ No newline at end of file
raise HTTPException(status_code=401, detail=str(e))
\ No newline at end of file
from fastapi import APIRouter, UploadFile, File, Depends
from src.application.use_cases.upload_chart import UploadChartUseCase
from src.dependencies import get_upload_use_case
router = APIRouter()
@router.post("/upload")
async def upload_chart(
file: UploadFile = File(...),
use_case: UploadChartUseCase = Depends(get_upload_use_case)
) -> dict:
image_bytes = await file.read()
image_id = use_case.execute(image_bytes, "user123")
return {"image_id": image_id}
\ No newline at end of file
......@@ -25,44 +25,3 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
yield session
finally:
await session.close()
chart_analyzer/ # Project root
├── docker-compose.yml
├── Dockerfile
├── generate.sh
├── requirements.txt
├── alembic.ini # ✅ At root
├── alembic/ # ✅ At root
├── versions/
├── env.py
└── script.py.mako
├── src/ # ✅ Only one src
├── application/ # Use cases, services
├── domain/ # Entities, business logic
├── infrastructure/ # DB, adapters, external services
├── adapters/
├── sqlmodel.py # Your Base, models
└── __init__.py
└── persistence/ # DB session, engine
├── database.py
└── __init__.py
├── presentation/ # FastAPI routers, DTOs
├── api/
├── v1/
├── users.py
└── charts.py
└── __init__.py
└── __init__.py
├── main.py # FastAPI app factory
└── config.py # Config, settings
├── tests/
├── unit/
├── integration/
└── conftest.py
├── .env
├── .gitignore
└── README.md
\ No newline at end of file
from fastapi import FastAPI
from infrastructure.api.fastapi.routes import auth
app = FastAPI(
title="Chart Analyzer API",
description="API for analyzing charts and managing users.",
version="1.0.0",
)
app.include_router(auth.router, prefix="/auth")
@app.get("/")
async def root():
return {"message": "Welcome to Chart Analyzer"}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment