From 19bf96d53480a5463162d2d18fd74b79ee4a43c5 Mon Sep 17 00:00:00 2001 From: Matteo Giustini Date: Tue, 28 Apr 2026 17:17:51 +0200 Subject: [PATCH] Primi files backend --- .env.example | 39 +++++++++++++++ backend/.dockerignore | 12 +++++ backend/Dockerfile | 17 +++++++ backend/alembic.ini | 41 ++++++++++++++++ backend/alembic/env.py | 54 ++++++++++++++++++++ backend/alembic/script.py.mako | 25 ++++++++++ backend/app/__init__.py | 0 backend/app/core/database.py | 17 +++++++ backend/app/core/deps.py | 56 +++++++++++++++++++++ backend/app/main.py | 55 +++++++++++++++++++++ backend/requirements.txt | 20 ++++++++ docker-compose.yml | 90 ++++++++++++++++++++++++++++++++++ 12 files changed, 426 insertions(+) create mode 100644 .env.example create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/app/__init__.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/main.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..893fc24 --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +POSTGRES_USER=gmg +POSTGRES_PASSWORD=gmgpassword +POSTGRES_DB=gmgdb + +DATABASE_URL=postgresql+asyncpg://gmg:gmgpassword@db:5432/gmgdb +REDIS_URL=redis://redis:6379/0 + +MINIO_ENDPOINT=minio:9000 +MINIO_PUBLIC_URL=http://localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin123 +MINIO_BUCKET_PHOTOS=photos +MINIO_BUCKET_DOCS=documents +MINIO_SECURE=false + +JWT_SECRET_KEY=changeme-super-secret-key-at-least-32-chars +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=15 +JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 + +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=noreply@gmgspa.com +SMTP_PASSWORD=smtppassword +SMTP_FROM=GMG Smart Quote + +MOTORNET_USER_ID=GmgTax77 +MOTORNET_PASSWORD=EtaxWs77 +MOTORNET_HOST=https://webservice.motornet.it/api/v3_0/rest/public +MOTORNET_OAUTH_HOST=https://webservice.motornet.it/auth/realms/webservices/protocol/openid-connect + +INDICATA_API_KEY= +EUROTAX_API_KEY= + +VEHICLE_CACHE_TTL_DAYS=90 +ALERT_DAYS_THRESHOLD=30 +REPAIR_ALERT_PCT=20 + +VITE_API_URL=http://localhost:8000 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..982d639 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,12 @@ +__pycache__ +*.pyc +*.pyo +.pytest_cache +.mypy_cache +.ruff_cache +.env +.venv +venv/ +*.egg-info/ +dist/ +build/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4646022 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..4481cf8 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = driver://user:pass@localhost/dbname + +[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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..356474e --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,54 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from alembic import context +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.core.config import settings +from app.core.database import Base +import app.models + +config = context.config + +sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql://") +config.set_main_option("sqlalchemy.url", sync_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + 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: + 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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +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"} diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..2283b5e --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from app.core.config import settings + +engine = create_async_engine(settings.database_url, echo=False) + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..fc00788 --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,56 @@ +from typing import AsyncGenerator +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.database import AsyncSessionLocal +from app.core.security import decode_token + +bearer_scheme = HTTPBearer() + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + db: AsyncSession = Depends(get_db), +): + from app.models.user import User + + token = credentials.credentials + payload = decode_token(token) + + if not payload or payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token non valido o scaduto", + ) + + user_id = payload.get("sub") + result = await db.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Utente non trovato o disabilitato", + ) + + return user + + +def require_roles(*roles: str): + async def checker(current_user=Depends(get_current_user)): + if current_user.role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permessi insufficienti", + ) + return current_user + return checker diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..567ada2 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,55 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +import structlog + +from app.api import auth, health, vehicles, valuations +from app.core.database import engine +from app.core import database as db_module + +logger = structlog.get_logger() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Avvio GMG Smart Quote backend") + yield + await engine.dispose() + logger.info("Shutdown completo") + + +app = FastAPI( + title="GMG Smart Quote API", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", + "http://localhost:3001", + "http://localhost:5173", + "http://127.0.0.1:3000", + "http://127.0.0.1:3001", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + logger.error("Unhandled exception", path=request.url.path, error=str(exc)) + return JSONResponse( + status_code=500, + content={"detail": "Errore interno del server"}, + ) + + +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(health.router, prefix="/api", tags=["health"]) +app.include_router(vehicles.router, prefix="/api/vehicles", tags=["vehicles"]) +app.include_router(valuations.router, prefix="/api/valuations", tags=["valuations"]) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1bedcf3 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,20 @@ +fastapi==0.111.1 +uvicorn[standard]==0.30.1 +sqlalchemy==2.0.30 +alembic==1.13.1 +asyncpg==0.29.0 +psycopg2-binary==2.9.9 +pydantic==2.7.1 +pydantic-settings==2.3.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.1.3 +python-multipart==0.0.9 +redis==5.0.4 +celery==5.4.0 +minio==7.2.7 +httpx==0.27.0 +structlog==24.2.0 +slowapi==0.1.9 +python-dotenv==1.0.1 +email-validator==2.1.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a9cbb49 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,90 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-gmg} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-gmgpassword} + POSTGRES_DB: ${POSTGRES_DB:-gmgdb} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-gmg} -d ${POSTGRES_DB:-gmgdb}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data + ports: + - "6380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + restart: unless-stopped + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin123} + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + ports: + - "9000:9000" + - "9001:9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + env_file: + - .env + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-gmg}:${POSTGRES_PASSWORD:-gmgpassword}@db:5432/${POSTGRES_DB:-gmgdb} + REDIS_URL: redis://redis:6379/0 + ports: + - "8001:8000" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./backend:/app + command: bash /app/start.sh + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + restart: unless-stopped + environment: + VITE_API_URL: "" + ports: + - "3001:3000" + depends_on: + - backend + volumes: + - ./frontend:/app + - /app/node_modules + command: npm run dev -- --host 0.0.0.0 --port 3000 + +volumes: + postgres_data: + redis_data: + minio_data: