diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..3296050 --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +import redis.asyncio as aioredis +from app.core.deps import get_db +from app.core.config import settings + +router = APIRouter() + + +@router.get("/health") +async def health_check(db: AsyncSession = Depends(get_db)): + checks = {"status": "ok", "db": "ok", "redis": "ok"} + + try: + await db.execute(text("SELECT 1")) + except Exception as e: + checks["db"] = f"error: {str(e)}" + checks["status"] = "degraded" + + try: + r = aioredis.from_url(settings.redis_url) + await r.ping() + await r.aclose() + except Exception as e: + checks["redis"] = f"error: {str(e)}" + checks["status"] = "degraded" + + return checks diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..19bb7f9 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,47 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import Optional + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + postgres_user: str = "gmg" + postgres_password: str = "gmgpassword" + postgres_db: str = "gmgdb" + + database_url: str = "postgresql+asyncpg://gmg:gmgpassword@db:5432/gmgdb" + redis_url: str = "redis://redis:6379/0" + + minio_endpoint: str = "minio:9000" + minio_public_url: str = "http://localhost:9000" + minio_access_key: str = "minioadmin" + minio_secret_key: str = "minioadmin123" + minio_bucket_photos: str = "photos" + minio_bucket_docs: str = "documents" + minio_secure: bool = False + + jwt_secret_key: str = "changeme-super-secret-key" + jwt_algorithm: str = "HS256" + jwt_access_token_expire_minutes: int = 15 + jwt_refresh_token_expire_days: int = 7 + + smtp_host: str = "smtp.example.com" + smtp_port: int = 587 + smtp_user: str = "noreply@example.com" + smtp_password: str = "" + smtp_from: str = "GMG Smart Quote " + + motornet_user_id: str = "GmgTax77" + motornet_password: str = "EtaxWs77" + motornet_host: str = "https://webservice.motornet.it/api/v3_0/rest/public" + motornet_oauth_host: str = "https://webservice.motornet.it/auth/realms/webservices/protocol/openid-connect" + + indicata_api_key: Optional[str] = None + eurotax_api_key: Optional[str] = None + + vehicle_cache_ttl_days: int = 90 + alert_days_threshold: int = 30 + repair_alert_pct: int = 20 + + +settings = Settings() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..6e8f801 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,41 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional, Any +from jose import JWTError, jwt +import bcrypt +from app.core.config import settings + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) + + +def create_access_token(subject: Any, extra_claims: Optional[dict] = None) -> str: + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.jwt_access_token_expire_minutes + ) + payload = {"sub": str(subject), "exp": expire, "type": "access"} + if extra_claims: + payload.update(extra_claims) + return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def create_refresh_token(subject: Any) -> str: + expire = datetime.now(timezone.utc) + timedelta( + days=settings.jwt_refresh_token_expire_days + ) + payload = {"sub": str(subject), "exp": expire, "type": "refresh"} + return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def decode_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode( + token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm] + ) + return payload + except JWTError: + return None diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..3fbf2d9 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,52 @@ +import enum +from datetime import datetime, timezone +from sqlalchemy import String, Boolean, DateTime, ForeignKey, Enum as SAEnum, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +class UserRole(str, enum.Enum): + venditore = "venditore" + valutatore = "valutatore" + backoffice = "backoffice" + operatore_perizie = "operatore_perizie" + approvatore_perizie = "approvatore_perizie" + admin = "admin" + + +class Group(Base): + __tablename__ = "groups" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[str | None] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + users: Mapped[list["User"]] = relationship("User", back_populates="group") + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + full_name: Mapped[str] = mapped_column(String(200), nullable=False) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), nullable=False) + group_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("groups.id"), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + notify_email: Mapped[bool] = mapped_column(Boolean, default=True) + notify_push: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + group: Mapped["Group | None"] = relationship("Group", back_populates="users")