Spaces:
Runtime error
Runtime error
ismailelghazi commited on
Commit ·
21f81f9
1
Parent(s): 6b42151
Upload Docker app
Browse files- Dockerfile +23 -0
- README.md +95 -10
- app/__init__.py +1 -0
- app/__pycache__/__init__.cpython-311.pyc +0 -0
- app/__pycache__/__init__.cpython-313.pyc +0 -0
- app/__pycache__/auth.cpython-311.pyc +0 -0
- app/__pycache__/auth.cpython-313.pyc +0 -0
- app/__pycache__/database.cpython-311.pyc +0 -0
- app/__pycache__/database.cpython-313.pyc +0 -0
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/__pycache__/main.cpython-313.pyc +0 -0
- app/__pycache__/models.cpython-311.pyc +0 -0
- app/__pycache__/models.cpython-313.pyc +0 -0
- app/__pycache__/schemas.cpython-311.pyc +0 -0
- app/__pycache__/schemas.cpython-313.pyc +0 -0
- app/auth.py +25 -0
- app/database.py +33 -0
- app/main.py +41 -0
- app/models.py +9 -0
- app/routers/__init__.py +1 -0
- app/routers/__pycache__/__init__.cpython-311.pyc +0 -0
- app/routers/__pycache__/__init__.cpython-313.pyc +0 -0
- app/routers/__pycache__/auth_router.cpython-311.pyc +0 -0
- app/routers/__pycache__/auth_router.cpython-313.pyc +0 -0
- app/routers/__pycache__/translate_router.cpython-311.pyc +0 -0
- app/routers/__pycache__/translate_router.cpython-313.pyc +0 -0
- app/routers/auth_router.py +49 -0
- app/routers/translate_router.py +23 -0
- app/schemas.py +23 -0
- app/utils/__init__.py +1 -0
- app/utils/__pycache__/__init__.cpython-311.pyc +0 -0
- app/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- app/utils/__pycache__/hashing.cpython-311.pyc +0 -0
- app/utils/__pycache__/hashing.cpython-313.pyc +0 -0
- app/utils/__pycache__/hf_client.cpython-311.pyc +0 -0
- app/utils/__pycache__/hf_client.cpython-313.pyc +0 -0
- app/utils/__pycache__/jwt_handler.cpython-311.pyc +0 -0
- app/utils/__pycache__/jwt_handler.cpython-313.pyc +0 -0
- app/utils/hashing.py +12 -0
- app/utils/hf_client.py +37 -0
- app/utils/jwt_handler.py +21 -0
- requirements.txt +12 -0
- test.db +0 -0
- tests/__init__.py +1 -0
- tests/__pycache__/__init__.cpython-311.pyc +0 -0
- tests/__pycache__/test_main.cpython-311-pytest-8.4.2.pyc +0 -0
- tests/test_main.py +106 -0
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set the working directory in the container
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies (needed for psycopg2)
|
| 8 |
+
RUN apt-get update && apt-get install -y libpq-dev gcc && rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Copy the requirements file into the container at /app
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
|
| 13 |
+
# Install any needed packages specified in requirements.txt
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
# Copy the rest of the application code
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
# Make port 8000 available to the world outside this container
|
| 20 |
+
EXPOSE 8000
|
| 21 |
+
|
| 22 |
+
# Run app.main:app when the container launches
|
| 23 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
@@ -1,10 +1,95 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TalAIt Translation Backend
|
| 2 |
+
|
| 3 |
+
A secure, private translation backend for TalAIt using FastAPI, PostgreSQL, and Hugging Face Inference API.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- **Authentication**: JWT stored in HTTP-only cookies.
|
| 8 |
+
- **Translation**: English <-> French translation using Helsinki-NLP models.
|
| 9 |
+
- **Security**: Password hashing with Bcrypt, secure cookie handling.
|
| 10 |
+
- **Database**: PostgreSQL for user storage.
|
| 11 |
+
- **Dockerized**: Easy deployment with Docker Compose.
|
| 12 |
+
|
| 13 |
+
## Architecture
|
| 14 |
+
|
| 15 |
+
1. **FastAPI**: Handles HTTP requests and routing.
|
| 16 |
+
2. **PostgreSQL**: Stores user credentials (hashed).
|
| 17 |
+
3. **Hugging Face API**: Performs the actual translation.
|
| 18 |
+
4. **JWT**: Manages session state via secure cookies.
|
| 19 |
+
|
| 20 |
+
### Authentication Flow
|
| 21 |
+
|
| 22 |
+
1. **Register**: User sends username/password -> Backend hashes password -> Stores in DB.
|
| 23 |
+
2. **Login**: User sends credentials -> Backend verifies -> Generates JWT -> Sets `access_token` HTTP-only cookie.
|
| 24 |
+
3. **Protected Routes**: Browser automatically sends cookie -> Backend validates JWT -> Grants access.
|
| 25 |
+
4. **Logout**: Backend clears the cookie.
|
| 26 |
+
|
| 27 |
+
## Setup & Running
|
| 28 |
+
|
| 29 |
+
### Prerequisites
|
| 30 |
+
|
| 31 |
+
- Docker & Docker Compose
|
| 32 |
+
- Hugging Face API Token
|
| 33 |
+
|
| 34 |
+
### Environment Variables
|
| 35 |
+
|
| 36 |
+
Create a `.env` file in the `backend` directory (or set them in `docker-compose.yml`):
|
| 37 |
+
|
| 38 |
+
```env
|
| 39 |
+
POSTGRES_USER=postgres
|
| 40 |
+
POSTGRES_PASSWORD=password
|
| 41 |
+
POSTGRES_DB=talait
|
| 42 |
+
HF_TOKEN=your_hf_token_here
|
| 43 |
+
JWT_SECRET=your_jwt_secret
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### Running Locally
|
| 47 |
+
|
| 48 |
+
1. Navigate to the `backend` directory:
|
| 49 |
+
```bash
|
| 50 |
+
cd backend
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
2. Install dependencies:
|
| 54 |
+
```bash
|
| 55 |
+
pip install -r requirements.txt
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
3. Start the server using the provided script (Windows):
|
| 59 |
+
```bash
|
| 60 |
+
.\start_backend.bat
|
| 61 |
+
```
|
| 62 |
+
Or manually:
|
| 63 |
+
```bash
|
| 64 |
+
uvicorn app.main:app --reload
|
| 65 |
+
```
|
| 66 |
+
**Note:** Make sure to run `uvicorn` from the `backend` directory, not `backend/app`.
|
| 67 |
+
|
| 68 |
+
The API will be available at `http://localhost:8000`.
|
| 69 |
+
Docs will be at `http://localhost:8000/docs`.
|
| 70 |
+
|
| 71 |
+
### Running with Docker
|
| 72 |
+
|
| 73 |
+
1. Navigate to the `backend` directory:
|
| 74 |
+
```bash
|
| 75 |
+
cd backend
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
2. Build and start the services:
|
| 79 |
+
```bash
|
| 80 |
+
docker-compose up --build
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
The API will be available at `http://localhost:8000`.
|
| 84 |
+
Adminer (DB GUI) will be at `http://localhost:8080`.
|
| 85 |
+
|
| 86 |
+
## API Endpoints
|
| 87 |
+
|
| 88 |
+
- `POST /register`: Create a new user.
|
| 89 |
+
- `POST /login`: Authenticate and receive cookie.
|
| 90 |
+
- `POST /logout`: Clear authentication cookie.
|
| 91 |
+
- `POST /translate`: Translate text (Requires Auth).
|
| 92 |
+
|
| 93 |
+
## Hugging Face Limits
|
| 94 |
+
|
| 95 |
+
The free Hugging Face Inference API has rate limits. If you encounter 503 errors, it means the model is loading. If you hit rate limits, consider upgrading to a paid plan or hosting the models yourself.
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# init
|
app/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (174 Bytes). View file
|
|
|
app/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (162 Bytes). View file
|
|
|
app/__pycache__/auth.cpython-311.pyc
ADDED
|
Binary file (1.85 kB). View file
|
|
|
app/__pycache__/auth.cpython-313.pyc
ADDED
|
Binary file (1.1 kB). View file
|
|
|
app/__pycache__/database.cpython-311.pyc
ADDED
|
Binary file (1.82 kB). View file
|
|
|
app/__pycache__/database.cpython-313.pyc
ADDED
|
Binary file (1.62 kB). View file
|
|
|
app/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (1.87 kB). View file
|
|
|
app/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (2.1 kB). View file
|
|
|
app/__pycache__/models.cpython-311.pyc
ADDED
|
Binary file (837 Bytes). View file
|
|
|
app/__pycache__/models.cpython-313.pyc
ADDED
|
Binary file (734 Bytes). View file
|
|
|
app/__pycache__/schemas.cpython-311.pyc
ADDED
|
Binary file (1.82 kB). View file
|
|
|
app/__pycache__/schemas.cpython-313.pyc
ADDED
|
Binary file (1.61 kB). View file
|
|
|
app/auth.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Request, HTTPException, status, Depends
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from .utils import jwt_handler
|
| 4 |
+
from .database import get_db
|
| 5 |
+
from . import models
|
| 6 |
+
|
| 7 |
+
def get_current_user(request: Request, db: Session = Depends(get_db)):
|
| 8 |
+
token = request.cookies.get("access_token")
|
| 9 |
+
if not token:
|
| 10 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
| 11 |
+
|
| 12 |
+
# Remove "Bearer " prefix if present
|
| 13 |
+
if token.startswith("Bearer "):
|
| 14 |
+
token = token.split(" ")[1]
|
| 15 |
+
|
| 16 |
+
payload = jwt_handler.verify_token(token)
|
| 17 |
+
if not payload:
|
| 18 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
|
| 19 |
+
|
| 20 |
+
username = payload.get("sub")
|
| 21 |
+
user = db.query(models.User).filter(models.User.username == username).first()
|
| 22 |
+
if not user:
|
| 23 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
| 24 |
+
|
| 25 |
+
return user
|
app/database.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
import os
|
| 5 |
+
import urllib.parse
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
POSTGRES_USER = os.getenv("POSTGRES_USER", "postgres")
|
| 11 |
+
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "password")
|
| 12 |
+
POSTGRES_SERVER = os.getenv("POSTGRES_SERVER", "localhost")
|
| 13 |
+
POSTGRES_DB = os.getenv("POSTGRES_DB", "talait")
|
| 14 |
+
|
| 15 |
+
encoded_user = urllib.parse.quote_plus(POSTGRES_USER)
|
| 16 |
+
encoded_password = urllib.parse.quote_plus(POSTGRES_PASSWORD)
|
| 17 |
+
|
| 18 |
+
DATABASE_URL = os.getenv("DATABASE_URL", f"postgresql://{encoded_user}:{encoded_password}@{POSTGRES_SERVER}/{POSTGRES_DB}")
|
| 19 |
+
|
| 20 |
+
engine = create_engine(
|
| 21 |
+
DATABASE_URL,
|
| 22 |
+
connect_args={"client_encoding": "utf8"}
|
| 23 |
+
)
|
| 24 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 25 |
+
|
| 26 |
+
Base = declarative_base()
|
| 27 |
+
|
| 28 |
+
def get_db():
|
| 29 |
+
db = SessionLocal()
|
| 30 |
+
try:
|
| 31 |
+
yield db
|
| 32 |
+
finally:
|
| 33 |
+
db.close()
|
app/main.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dotenv import load_dotenv
|
| 2 |
+
load_dotenv()
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI, Request
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
from .database import engine, Base
|
| 9 |
+
from .routers import auth_router, translate_router
|
| 10 |
+
|
| 11 |
+
@asynccontextmanager
|
| 12 |
+
async def lifespan(app: FastAPI):
|
| 13 |
+
# Create tables
|
| 14 |
+
Base.metadata.create_all(bind=engine)
|
| 15 |
+
yield
|
| 16 |
+
|
| 17 |
+
app = FastAPI(title="TalAIt Backend", lifespan=lifespan)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# CORS Configuration
|
| 22 |
+
origins = [
|
| 23 |
+
"http://localhost",
|
| 24 |
+
"http://localhost:3000",
|
| 25 |
+
"http://localhost:8080",
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
app.add_middleware(
|
| 29 |
+
CORSMiddleware,
|
| 30 |
+
allow_origins=origins,
|
| 31 |
+
allow_credentials=True,
|
| 32 |
+
allow_methods=["*"],
|
| 33 |
+
allow_headers=["*"],
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
app.include_router(auth_router.router)
|
| 37 |
+
app.include_router(translate_router.router)
|
| 38 |
+
|
| 39 |
+
@app.get("/")
|
| 40 |
+
def root():
|
| 41 |
+
return {"message": "Welcome to TalAIt Translation API"}
|
app/models.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String
|
| 2 |
+
from .database import Base
|
| 3 |
+
|
| 4 |
+
class User(Base):
|
| 5 |
+
__tablename__ = "users"
|
| 6 |
+
|
| 7 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 8 |
+
username = Column(String, unique=True, index=True, nullable=False)
|
| 9 |
+
password_hash = Column(String, nullable=False)
|
app/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# init
|
app/routers/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (182 Bytes). View file
|
|
|
app/routers/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (170 Bytes). View file
|
|
|
app/routers/__pycache__/auth_router.cpython-311.pyc
ADDED
|
Binary file (3.89 kB). View file
|
|
|
app/routers/__pycache__/auth_router.cpython-313.pyc
ADDED
|
Binary file (3.27 kB). View file
|
|
|
app/routers/__pycache__/translate_router.cpython-311.pyc
ADDED
|
Binary file (1.89 kB). View file
|
|
|
app/routers/__pycache__/translate_router.cpython-313.pyc
ADDED
|
Binary file (1.66 kB). View file
|
|
|
app/routers/auth_router.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from .. import schemas, models, database, auth
|
| 4 |
+
from ..utils import hashing, jwt_handler
|
| 5 |
+
|
| 6 |
+
router = APIRouter(tags=["Authentication"])
|
| 7 |
+
|
| 8 |
+
@router.post("/register", response_model=schemas.UserResponse)
|
| 9 |
+
def register(user: schemas.UserCreate, db: Session = Depends(database.get_db)):
|
| 10 |
+
user_exists = db.query(models.User).filter(models.User.username == user.username).first()
|
| 11 |
+
if user_exists:
|
| 12 |
+
raise HTTPException(status_code=400, detail="Username already registered")
|
| 13 |
+
|
| 14 |
+
new_user = models.User(username=user.username, password_hash=hashing.Hash.bcrypt(user.password))
|
| 15 |
+
db.add(new_user)
|
| 16 |
+
db.commit()
|
| 17 |
+
db.refresh(new_user)
|
| 18 |
+
return new_user
|
| 19 |
+
|
| 20 |
+
@router.post("/login")
|
| 21 |
+
def login(response: Response, user: schemas.UserCreate, db: Session = Depends(database.get_db)):
|
| 22 |
+
db_user = db.query(models.User).filter(models.User.username == user.username).first()
|
| 23 |
+
if not db_user:
|
| 24 |
+
raise HTTPException(status_code=404, detail="Invalid Credentials")
|
| 25 |
+
|
| 26 |
+
if not hashing.Hash.verify(user.password, db_user.password_hash):
|
| 27 |
+
raise HTTPException(status_code=404, detail="Invalid Credentials")
|
| 28 |
+
|
| 29 |
+
access_token = jwt_handler.create_access_token(data={"sub": db_user.username})
|
| 30 |
+
|
| 31 |
+
# Set HTTP-only cookie
|
| 32 |
+
response.set_cookie(
|
| 33 |
+
key="access_token",
|
| 34 |
+
value=f"Bearer {access_token}",
|
| 35 |
+
httponly=True,
|
| 36 |
+
secure=False, # Set to False for localhost (HTTP)
|
| 37 |
+
samesite="lax"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
return {"message": "logged in"}
|
| 41 |
+
|
| 42 |
+
@router.post("/logout")
|
| 43 |
+
def logout(response: Response):
|
| 44 |
+
response.delete_cookie("access_token")
|
| 45 |
+
return {"message": "logged out"}
|
| 46 |
+
|
| 47 |
+
@router.get("/me", response_model=schemas.UserResponse)
|
| 48 |
+
def read_users_me(current_user: models.User = Depends(auth.get_current_user)):
|
| 49 |
+
return current_user
|
app/routers/translate_router.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 2 |
+
from .. import schemas, auth, models
|
| 3 |
+
from ..utils import hf_client
|
| 4 |
+
|
| 5 |
+
router = APIRouter(tags=["Translation"])
|
| 6 |
+
|
| 7 |
+
@router.post("/translate", response_model=schemas.TranslationResponse)
|
| 8 |
+
def translate(request: schemas.TranslationRequest, user: models.User = Depends(auth.get_current_user)):
|
| 9 |
+
if not request.text:
|
| 10 |
+
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
translation = hf_client.translate_text(request.text, request.direction)
|
| 14 |
+
except ValueError as e:
|
| 15 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 16 |
+
|
| 17 |
+
if isinstance(translation, dict) and translation.get("status") == 503:
|
| 18 |
+
raise HTTPException(status_code=503, detail=translation.get("error"))
|
| 19 |
+
|
| 20 |
+
if translation is None:
|
| 21 |
+
raise HTTPException(status_code=502, detail="Translation service unavailable")
|
| 22 |
+
|
| 23 |
+
return {"translation": translation}
|
app/schemas.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
|
| 3 |
+
class UserCreate(BaseModel):
|
| 4 |
+
username: str
|
| 5 |
+
password: str
|
| 6 |
+
|
| 7 |
+
class UserResponse(BaseModel):
|
| 8 |
+
id: int
|
| 9 |
+
username: str
|
| 10 |
+
|
| 11 |
+
class Config:
|
| 12 |
+
from_attributes = True
|
| 13 |
+
|
| 14 |
+
class Token(BaseModel):
|
| 15 |
+
access_token: str
|
| 16 |
+
token_type: str
|
| 17 |
+
|
| 18 |
+
class TranslationRequest(BaseModel):
|
| 19 |
+
text: str
|
| 20 |
+
direction: str # "fr-en" or "en-fr"
|
| 21 |
+
|
| 22 |
+
class TranslationResponse(BaseModel):
|
| 23 |
+
translation: str
|
app/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# init
|
app/utils/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (180 Bytes). View file
|
|
|
app/utils/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (168 Bytes). View file
|
|
|
app/utils/__pycache__/hashing.cpython-311.pyc
ADDED
|
Binary file (1.12 kB). View file
|
|
|
app/utils/__pycache__/hashing.cpython-313.pyc
ADDED
|
Binary file (997 Bytes). View file
|
|
|
app/utils/__pycache__/hf_client.cpython-311.pyc
ADDED
|
Binary file (1.98 kB). View file
|
|
|
app/utils/__pycache__/hf_client.cpython-313.pyc
ADDED
|
Binary file (1.8 kB). View file
|
|
|
app/utils/__pycache__/jwt_handler.cpython-311.pyc
ADDED
|
Binary file (1.5 kB). View file
|
|
|
app/utils/__pycache__/jwt_handler.cpython-313.pyc
ADDED
|
Binary file (1.34 kB). View file
|
|
|
app/utils/hashing.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from passlib.context import CryptContext
|
| 2 |
+
|
| 3 |
+
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
| 4 |
+
|
| 5 |
+
class Hash:
|
| 6 |
+
@staticmethod
|
| 7 |
+
def bcrypt(password: str):
|
| 8 |
+
return pwd_context.hash(password)
|
| 9 |
+
|
| 10 |
+
@staticmethod
|
| 11 |
+
def verify(plain_password, hashed_password):
|
| 12 |
+
return pwd_context.verify(plain_password, hashed_password)
|
app/utils/hf_client.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 5 |
+
API_URL_TEMPLATE = "https://router.huggingface.co/hf-inference/models/{model}"
|
| 6 |
+
|
| 7 |
+
MODELS = {
|
| 8 |
+
"fr-en": "Helsinki-NLP/opus-mt-fr-en",
|
| 9 |
+
"en-fr": "Helsinki-NLP/opus-mt-en-fr"
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
def translate_text(text: str, direction: str):
|
| 13 |
+
if direction not in MODELS:
|
| 14 |
+
raise ValueError("Invalid direction")
|
| 15 |
+
|
| 16 |
+
model = MODELS[direction]
|
| 17 |
+
api_url = API_URL_TEMPLATE.format(model=model)
|
| 18 |
+
headers = {"Authorization": f"Bearer {HF_TOKEN}"}
|
| 19 |
+
payload = {"inputs": text}
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
response = requests.post(api_url, headers=headers, json=payload, timeout=10)
|
| 23 |
+
|
| 24 |
+
if response.status_code == 503:
|
| 25 |
+
# Model loading
|
| 26 |
+
return {"error": "Model is loading, please try again shortly", "status": 503}
|
| 27 |
+
|
| 28 |
+
response.raise_for_status()
|
| 29 |
+
result = response.json()
|
| 30 |
+
|
| 31 |
+
if isinstance(result, list) and len(result) > 0 and 'translation_text' in result[0]:
|
| 32 |
+
return result[0]['translation_text']
|
| 33 |
+
else:
|
| 34 |
+
return None
|
| 35 |
+
except requests.exceptions.RequestException as e:
|
| 36 |
+
print(f"HF API Error: {e}")
|
| 37 |
+
return None
|
app/utils/jwt_handler.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
from jose import jwt, JWTError
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
SECRET_KEY = os.getenv("JWT_SECRET", "secret")
|
| 6 |
+
ALGORITHM = "HS256"
|
| 7 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
| 8 |
+
|
| 9 |
+
def create_access_token(data: dict):
|
| 10 |
+
to_encode = data.copy()
|
| 11 |
+
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 12 |
+
to_encode.update({"exp": expire})
|
| 13 |
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 14 |
+
return encoded_jwt
|
| 15 |
+
|
| 16 |
+
def verify_token(token: str):
|
| 17 |
+
try:
|
| 18 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 19 |
+
return payload
|
| 20 |
+
except JWTError:
|
| 21 |
+
return None
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
sqlalchemy
|
| 4 |
+
psycopg2-binary
|
| 5 |
+
python-jose[cryptography]
|
| 6 |
+
passlib[argon2]
|
| 7 |
+
argon2-cffi
|
| 8 |
+
python-multipart
|
| 9 |
+
requests
|
| 10 |
+
python-dotenv
|
| 11 |
+
httpx
|
| 12 |
+
pytest
|
test.db
ADDED
|
Binary file (16.4 kB). View file
|
|
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# init
|
tests/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (176 Bytes). View file
|
|
|
tests/__pycache__/test_main.cpython-311-pytest-8.4.2.pyc
ADDED
|
Binary file (9.81 kB). View file
|
|
|
tests/test_main.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
# Set env vars before importing app modules to ensure they use the test DB
|
| 3 |
+
os.environ["DATABASE_URL"] = "sqlite:///./test.db"
|
| 4 |
+
os.environ["POSTGRES_USER"] = "user"
|
| 5 |
+
os.environ["POSTGRES_PASSWORD"] = "password"
|
| 6 |
+
os.environ["POSTGRES_DB"] = "dbname"
|
| 7 |
+
os.environ["JWT_SECRET"] = "testsecret"
|
| 8 |
+
os.environ["HF_TOKEN"] = "testtoken"
|
| 9 |
+
|
| 10 |
+
from fastapi.testclient import TestClient
|
| 11 |
+
from sqlalchemy import create_engine, StaticPool
|
| 12 |
+
from sqlalchemy.orm import sessionmaker
|
| 13 |
+
from app.main import app
|
| 14 |
+
from app.database import Base, get_db
|
| 15 |
+
from app.utils import hf_client
|
| 16 |
+
from unittest.mock import MagicMock
|
| 17 |
+
|
| 18 |
+
# Use SQLite for testing
|
| 19 |
+
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
|
| 20 |
+
|
| 21 |
+
engine = create_engine(
|
| 22 |
+
SQLALCHEMY_DATABASE_URL,
|
| 23 |
+
connect_args={"check_same_thread": False},
|
| 24 |
+
poolclass=StaticPool,
|
| 25 |
+
)
|
| 26 |
+
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 27 |
+
|
| 28 |
+
# Patch the app's engine to use our test engine
|
| 29 |
+
from app import database
|
| 30 |
+
database.engine = engine
|
| 31 |
+
|
| 32 |
+
Base.metadata.create_all(bind=engine)
|
| 33 |
+
|
| 34 |
+
def override_get_db():
|
| 35 |
+
try:
|
| 36 |
+
db = TestingSessionLocal()
|
| 37 |
+
yield db
|
| 38 |
+
finally:
|
| 39 |
+
db.close()
|
| 40 |
+
|
| 41 |
+
app.dependency_overrides[get_db] = override_get_db
|
| 42 |
+
|
| 43 |
+
client = TestClient(app)
|
| 44 |
+
|
| 45 |
+
def test_register():
|
| 46 |
+
response = client.post(
|
| 47 |
+
"/register",
|
| 48 |
+
json={"username": "testuser", "password": "testpassword"},
|
| 49 |
+
)
|
| 50 |
+
assert response.status_code == 200
|
| 51 |
+
assert response.json()["username"] == "testuser"
|
| 52 |
+
|
| 53 |
+
def test_login():
|
| 54 |
+
client.post(
|
| 55 |
+
"/register",
|
| 56 |
+
json={"username": "testuser2", "password": "testpassword"},
|
| 57 |
+
)
|
| 58 |
+
response = client.post(
|
| 59 |
+
"/login",
|
| 60 |
+
json={"username": "testuser2", "password": "testpassword"},
|
| 61 |
+
)
|
| 62 |
+
assert response.status_code == 200
|
| 63 |
+
assert "access_token" in response.cookies
|
| 64 |
+
|
| 65 |
+
def test_login_wrong_password():
|
| 66 |
+
client.post(
|
| 67 |
+
"/register",
|
| 68 |
+
json={"username": "testuser3", "password": "testpassword"},
|
| 69 |
+
)
|
| 70 |
+
response = client.post(
|
| 71 |
+
"/login",
|
| 72 |
+
json={"username": "testuser3", "password": "wrongpassword"},
|
| 73 |
+
)
|
| 74 |
+
assert response.status_code == 404
|
| 75 |
+
|
| 76 |
+
def test_translate_no_cookie():
|
| 77 |
+
response = client.post(
|
| 78 |
+
"/translate",
|
| 79 |
+
json={"text": "Hello", "direction": "en-fr"},
|
| 80 |
+
)
|
| 81 |
+
assert response.status_code == 401
|
| 82 |
+
|
| 83 |
+
def test_translate_success(monkeypatch):
|
| 84 |
+
# Mock HF API
|
| 85 |
+
def mock_translate(*args, **kwargs):
|
| 86 |
+
return "Bonjour"
|
| 87 |
+
|
| 88 |
+
monkeypatch.setattr(hf_client, "translate_text", mock_translate)
|
| 89 |
+
|
| 90 |
+
# Login first
|
| 91 |
+
client.post(
|
| 92 |
+
"/register",
|
| 93 |
+
json={"username": "testuser4", "password": "testpassword"},
|
| 94 |
+
)
|
| 95 |
+
login_response = client.post(
|
| 96 |
+
"/login",
|
| 97 |
+
json={"username": "testuser4", "password": "testpassword"},
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
response = client.post(
|
| 101 |
+
"/translate",
|
| 102 |
+
json={"text": "Hello", "direction": "en-fr"},
|
| 103 |
+
cookies=login_response.cookies
|
| 104 |
+
)
|
| 105 |
+
assert response.status_code == 200
|
| 106 |
+
assert response.json()["translation"] == "Bonjour"
|