innovationhub-cli 1.1.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +148 -86
- package/index.js +228 -29
- package/package.json +12 -3
- package/templates/nest/.env.example +41 -0
- package/templates/nest/.prettierrc +4 -0
- package/templates/nest/Dockerfile +17 -0
- package/templates/nest/README.md +224 -0
- package/templates/nest/addons/.github/dependabot.yml +11 -0
- package/templates/nest/addons/.github/labeler.yml +34 -0
- package/templates/nest/addons/.github/workflows/check-branch.yml +24 -0
- package/templates/nest/addons/.github/workflows/ci.yml +29 -0
- package/templates/nest/addons/.github/workflows/draft-release.yml +22 -0
- package/templates/nest/addons/.github/workflows/pr-labeler.yml +16 -0
- package/templates/nest/addons/.github/workflows/release.yml +33 -0
- package/templates/nest/addons/.github/workflows/security-audit.yml +17 -0
- package/templates/nest/addons/.github/workflows/semantic-pr.yml +31 -0
- package/templates/nest/addons/.github/workflows/stale.yml +24 -0
- package/templates/nest/addons/.husky/commit-msg +17 -0
- package/templates/nest/addons/.husky/pre-commit +1 -0
- package/templates/nest/addons/addons.json +17 -0
- package/templates/nest/addons/cloudinary/cloudinary.module.ts +11 -0
- package/templates/nest/addons/cloudinary/cloudinary.provider.ts +16 -0
- package/templates/nest/addons/cloudinary/cloudinary.service.ts +54 -0
- package/templates/nest/docker-compose.yml +58 -0
- package/templates/nest/eslint.config.mjs +34 -0
- package/templates/nest/jest.config.js +17 -0
- package/templates/nest/nest-cli.json +12 -0
- package/templates/nest/package.json +99 -0
- package/templates/nest/src/app.controller.ts +7 -0
- package/templates/nest/src/app.module.ts +69 -0
- package/templates/nest/src/app.service.ts +4 -0
- package/templates/nest/src/auth/auth.controller.ts +97 -0
- package/templates/nest/src/auth/auth.module.ts +46 -0
- package/templates/nest/src/auth/auth.service.ts +231 -0
- package/templates/nest/src/auth/decorators/roles.decorator.ts +5 -0
- package/templates/nest/src/auth/dto/change-password.dto.ts +21 -0
- package/templates/nest/src/auth/dto/login-response.dto.ts +25 -0
- package/templates/nest/src/auth/dto/login.dto.ts +15 -0
- package/templates/nest/src/auth/dto/refresh-token.dto.ts +12 -0
- package/templates/nest/src/auth/entities/refresh-token.entity.ts +18 -0
- package/templates/nest/src/auth/enums/role.enum.ts +4 -0
- package/templates/nest/src/auth/guards/jwt-auth.guard.ts +5 -0
- package/templates/nest/src/auth/guards/refresh-token.guard.ts +5 -0
- package/templates/nest/src/auth/guards/roles.guard.ts +23 -0
- package/templates/nest/src/auth/interfaces/jwt-payload.interface.ts +10 -0
- package/templates/nest/src/auth/strategies/jwt.strategy.ts +28 -0
- package/templates/nest/src/auth/strategies/local.strategy.ts +23 -0
- package/templates/nest/src/auth/strategies/refresh-token.strategy.ts +32 -0
- package/templates/nest/src/common/base.entity.ts +19 -0
- package/templates/nest/src/common/base.repository.ts +79 -0
- package/templates/nest/src/common/base.service.ts +28 -0
- package/templates/nest/src/common/constants/errors.constants.ts +33 -0
- package/templates/nest/src/common/decorators/user.decorator.ts +9 -0
- package/templates/nest/src/common/dto/base-query.dto.ts +56 -0
- package/templates/nest/src/common/irepository.ts +18 -0
- package/templates/nest/src/common/utils/duration.utils.ts +33 -0
- package/templates/nest/src/common/utils/pagination.utils.ts +35 -0
- package/templates/nest/src/common/utils/slug.utils.ts +14 -0
- package/templates/nest/src/common/utils/transform.utils.ts +62 -0
- package/templates/nest/src/common/validators/is-date-after.validator.ts +40 -0
- package/templates/nest/src/data-source.ts +23 -0
- package/templates/nest/src/main.ts +44 -0
- package/templates/nest/src/user/dto/create-user.dto.ts +50 -0
- package/templates/nest/src/user/dto/query-users.dto.ts +23 -0
- package/templates/nest/src/user/dto/update-user.dto.ts +15 -0
- package/templates/nest/src/user/entities/user.entity.ts +66 -0
- package/templates/nest/src/user/user.controller.ts +172 -0
- package/templates/nest/src/user/user.module.ts +15 -0
- package/templates/nest/src/user/user.repository.ts +61 -0
- package/templates/nest/src/user/user.service.ts +138 -0
- package/templates/nest/template.json +5 -0
- package/templates/nest/test/jest-e2e.json +12 -0
- package/templates/nest/tsconfig.build.json +4 -0
- package/templates/nest/tsconfig.json +25 -0
- package/templates/python/.env.example +37 -0
- package/templates/python/Dockerfile +18 -0
- package/templates/python/README.md +219 -0
- package/templates/python/addons/.github/dependabot.yml +11 -0
- package/templates/python/addons/.github/labeler.yml +29 -0
- package/templates/python/addons/.github/workflows/check-branch.yml +24 -0
- package/templates/python/addons/.github/workflows/ci.yml +30 -0
- package/templates/python/addons/.github/workflows/draft-release.yml +22 -0
- package/templates/python/addons/.github/workflows/pr-labeler.yml +16 -0
- package/templates/python/addons/.github/workflows/release.yml +30 -0
- package/templates/python/addons/.github/workflows/security-audit.yml +21 -0
- package/templates/python/addons/.github/workflows/semantic-pr.yml +31 -0
- package/templates/python/addons/.github/workflows/stale.yml +24 -0
- package/templates/python/addons/addons.json +17 -0
- package/templates/python/addons/cloudinary/service.py +67 -0
- package/templates/python/addons/pre-commit/.pre-commit-config.yaml +31 -0
- package/templates/python/alembic/env.py +56 -0
- package/templates/python/alembic/script.py.mako +26 -0
- package/templates/python/alembic/versions/.gitkeep +0 -0
- package/templates/python/alembic.ini +39 -0
- package/templates/python/app/__init__.py +0 -0
- package/templates/python/app/auth/__init__.py +5 -0
- package/templates/python/app/auth/dependencies.py +118 -0
- package/templates/python/app/auth/enums.py +6 -0
- package/templates/python/app/auth/models.py +18 -0
- package/templates/python/app/auth/router.py +68 -0
- package/templates/python/app/auth/schemas.py +58 -0
- package/templates/python/app/auth/service.py +180 -0
- package/templates/python/app/common/__init__.py +18 -0
- package/templates/python/app/common/base_model.py +26 -0
- package/templates/python/app/common/base_repository.py +83 -0
- package/templates/python/app/common/errors.py +35 -0
- package/templates/python/app/common/pagination.py +22 -0
- package/templates/python/app/common/schemas.py +20 -0
- package/templates/python/app/common/utils.py +15 -0
- package/templates/python/app/core/__init__.py +4 -0
- package/templates/python/app/core/config.py +55 -0
- package/templates/python/app/core/database.py +20 -0
- package/templates/python/app/main.py +33 -0
- package/templates/python/app/user/__init__.py +4 -0
- package/templates/python/app/user/models.py +26 -0
- package/templates/python/app/user/repository.py +84 -0
- package/templates/python/app/user/router.py +170 -0
- package/templates/python/app/user/schemas.py +60 -0
- package/templates/python/app/user/service.py +114 -0
- package/templates/python/docker-compose.yml +55 -0
- package/templates/python/pyproject.toml +46 -0
- package/templates/python/requirements-dev.txt +7 -0
- package/templates/python/requirements.txt +20 -0
- package/templates/python/template.json +5 -0
- package/utils/template.js +165 -0
- package/utils/git.js +0 -71
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SortOrder(StrEnum):
|
|
7
|
+
ASC = "ASC"
|
|
8
|
+
DESC = "DESC"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseQueryParams(BaseModel):
|
|
12
|
+
"""Parâmetros base de query com paginação e ordenação."""
|
|
13
|
+
|
|
14
|
+
page: int = Field(default=1, ge=1, description="Número da página que deseja buscar.")
|
|
15
|
+
limit: int = Field(
|
|
16
|
+
default=10, ge=1, le=100, description="Quantidade de itens por página."
|
|
17
|
+
)
|
|
18
|
+
search: str | None = Field(default=None, description="Termo de busca.")
|
|
19
|
+
sort_by: str = Field(default="created_at", description="Coluna de ordenação.")
|
|
20
|
+
sort_order: SortOrder = Field(default=SortOrder.DESC, description="Direção da ordenação.")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import unicodedata
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def create_slug(title: str) -> str:
|
|
6
|
+
"""Gera um slug URL-friendly a partir de um título."""
|
|
7
|
+
slug = unicodedata.normalize("NFD", title.lower())
|
|
8
|
+
slug = re.sub(r"[\u0300-\u036f]", "", slug) # Remove acentos
|
|
9
|
+
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
|
10
|
+
slug = slug.strip("-")
|
|
11
|
+
|
|
12
|
+
if len(slug) > 50:
|
|
13
|
+
slug = slug[:50].rstrip("-")
|
|
14
|
+
|
|
15
|
+
return slug
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Settings(BaseSettings):
|
|
5
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
|
6
|
+
|
|
7
|
+
# Database
|
|
8
|
+
DATABASE_HOST: str = "localhost"
|
|
9
|
+
DATABASE_PORT: int = 5432
|
|
10
|
+
DATABASE_USERNAME: str = "postgres"
|
|
11
|
+
DATABASE_PASSWORD: str = "docker"
|
|
12
|
+
DATABASE_NAME: str = "innovationhub"
|
|
13
|
+
DATABASE_SSL: bool = False
|
|
14
|
+
|
|
15
|
+
# JWT
|
|
16
|
+
JWT_SECRET: str = "change-me"
|
|
17
|
+
JWT_EXPIRATION_MINUTES: int = 15
|
|
18
|
+
JWT_REFRESH_SECRET: str = "change-me-refresh"
|
|
19
|
+
JWT_REFRESH_EXPIRATION_DAYS: int = 7
|
|
20
|
+
|
|
21
|
+
# Default password
|
|
22
|
+
DEFAULT_PASSWORD: str = "ih123"
|
|
23
|
+
|
|
24
|
+
# CORS
|
|
25
|
+
CORS_ORIGIN: str = "http://localhost:3001"
|
|
26
|
+
|
|
27
|
+
# Mail
|
|
28
|
+
MAIL_HOST: str = "smtp.gmail.com"
|
|
29
|
+
MAIL_PORT: int = 587
|
|
30
|
+
MAIL_USER: str = ""
|
|
31
|
+
MAIL_PASSWORD: str = ""
|
|
32
|
+
MAIL_FROM: str = ""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def database_url(self) -> str:
|
|
36
|
+
scheme = "postgresql+asyncpg"
|
|
37
|
+
return (
|
|
38
|
+
f"{scheme}://{self.DATABASE_USERNAME}:{self.DATABASE_PASSWORD}"
|
|
39
|
+
f"@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def database_url_sync(self) -> str:
|
|
44
|
+
"""URL síncrona para uso com Alembic."""
|
|
45
|
+
return (
|
|
46
|
+
f"postgresql://{self.DATABASE_USERNAME}:{self.DATABASE_PASSWORD}"
|
|
47
|
+
f"@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def cors_origins(self) -> list[str]:
|
|
52
|
+
return [origin.strip() for origin in self.CORS_ORIGIN.split(",") if origin.strip()]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
settings = Settings()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
4
|
+
|
|
5
|
+
from app.core.config import settings
|
|
6
|
+
|
|
7
|
+
engine = create_async_engine(settings.database_url, echo=False, future=True)
|
|
8
|
+
|
|
9
|
+
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
13
|
+
"""Dependency que fornece uma sessão async do banco de dados."""
|
|
14
|
+
async with async_session_factory() as session:
|
|
15
|
+
try:
|
|
16
|
+
yield session
|
|
17
|
+
await session.commit()
|
|
18
|
+
except Exception:
|
|
19
|
+
await session.rollback()
|
|
20
|
+
raise
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
3
|
+
|
|
4
|
+
from app.auth.router import router as auth_router
|
|
5
|
+
from app.core.config import settings
|
|
6
|
+
from app.user.router import router as user_router
|
|
7
|
+
|
|
8
|
+
app = FastAPI(
|
|
9
|
+
title="API da Landing Page/Blog",
|
|
10
|
+
description="Documentação da API do backend (FastAPI) para Autenticação e Blog.",
|
|
11
|
+
version="1.0.0",
|
|
12
|
+
docs_url="/api/docs",
|
|
13
|
+
redoc_url="/api/redoc",
|
|
14
|
+
openapi_url="/api/openapi.json",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# CORS
|
|
18
|
+
app.add_middleware(
|
|
19
|
+
CORSMiddleware,
|
|
20
|
+
allow_origins=settings.cors_origins,
|
|
21
|
+
allow_credentials=True,
|
|
22
|
+
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
|
23
|
+
allow_headers=["Content-Type", "Authorization"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Routers
|
|
27
|
+
app.include_router(auth_router)
|
|
28
|
+
app.include_router(user_router)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.get("/", tags=["health"])
|
|
32
|
+
async def health_check():
|
|
33
|
+
return {"status": "ok"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Boolean, Column, DateTime, Enum, String
|
|
4
|
+
from sqlalchemy.orm import relationship
|
|
5
|
+
|
|
6
|
+
from app.auth.enums import Role
|
|
7
|
+
from app.common.base_model import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class User(BaseModel):
|
|
11
|
+
|
|
12
|
+
__tablename__ = "users"
|
|
13
|
+
|
|
14
|
+
email: str = Column(String, unique=True, nullable=False, index=True)
|
|
15
|
+
name: str = Column(String, nullable=False)
|
|
16
|
+
phone: str | None = Column(String, nullable=True)
|
|
17
|
+
password: str = Column(String, nullable=False)
|
|
18
|
+
is_active: bool = Column(Boolean, default=True, nullable=False)
|
|
19
|
+
role: str = Column(Enum(Role, name="role_enum"), default=Role.USER, nullable=False)
|
|
20
|
+
must_change_password: bool = Column(Boolean, default=False, nullable=False)
|
|
21
|
+
deleted_at: datetime | None = Column(DateTime(timezone=True), nullable=True)
|
|
22
|
+
|
|
23
|
+
# Relationships
|
|
24
|
+
refresh_tokens = relationship(
|
|
25
|
+
"RefreshToken", back_populates="user", cascade="all, delete-orphan"
|
|
26
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import desc, func, or_, select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from app.common.base_repository import BaseRepository
|
|
7
|
+
from app.common.pagination import PaginatedResult, PaginationMeta
|
|
8
|
+
from app.user.models import User
|
|
9
|
+
from app.user.schemas import QueryUsersParams
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UserRepository(BaseRepository[User]):
|
|
13
|
+
"""Repositório de usuários."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, session: AsyncSession):
|
|
16
|
+
super().__init__(User, session)
|
|
17
|
+
|
|
18
|
+
async def find_by_id(self, id: uuid.UUID) -> User | None:
|
|
19
|
+
"""Busca usuário ativo (sem soft delete) pelo ID."""
|
|
20
|
+
stmt = select(User).where(User.id == id, User.deleted_at.is_(None))
|
|
21
|
+
result = await self.session.execute(stmt)
|
|
22
|
+
return result.scalar_one_or_none()
|
|
23
|
+
|
|
24
|
+
async def find_all(self) -> list[User]:
|
|
25
|
+
"""Busca todos os usuários ativos."""
|
|
26
|
+
stmt = select(User).where(User.deleted_at.is_(None)).order_by(desc(User.created_at))
|
|
27
|
+
result = await self.session.execute(stmt)
|
|
28
|
+
return list(result.scalars().all())
|
|
29
|
+
|
|
30
|
+
async def find_by_email(self, email: str) -> User | None:
|
|
31
|
+
"""Busca usuário pelo email."""
|
|
32
|
+
stmt = select(User).where(User.email == email)
|
|
33
|
+
result = await self.session.execute(stmt)
|
|
34
|
+
return result.scalar_one_or_none()
|
|
35
|
+
|
|
36
|
+
async def find_and_count_users(self, query: QueryUsersParams) -> PaginatedResult[User]:
|
|
37
|
+
"""Busca paginada com filtro por nome/email."""
|
|
38
|
+
base_stmt = select(User).where(User.deleted_at.is_(None))
|
|
39
|
+
count_stmt = select(func.count()).select_from(User).where(User.deleted_at.is_(None))
|
|
40
|
+
|
|
41
|
+
if query.search:
|
|
42
|
+
search_filter = or_(
|
|
43
|
+
User.name.ilike(f"%{query.search}%"),
|
|
44
|
+
User.email.ilike(f"%{query.search}%"),
|
|
45
|
+
)
|
|
46
|
+
base_stmt = base_stmt.where(search_filter)
|
|
47
|
+
count_stmt = count_stmt.where(search_filter)
|
|
48
|
+
|
|
49
|
+
# Whitelist de colunas para ordenação
|
|
50
|
+
allowed_columns = {"id", "name", "email", "is_active", "role", "created_at"}
|
|
51
|
+
sort_col = query.sort_by if query.sort_by in allowed_columns else "created_at"
|
|
52
|
+
column = getattr(User, sort_col, User.created_at)
|
|
53
|
+
order = desc(column) if query.sort_order.value == "DESC" else column.asc()
|
|
54
|
+
|
|
55
|
+
base_stmt = base_stmt.order_by(order)
|
|
56
|
+
base_stmt = base_stmt.offset((query.page - 1) * query.limit).limit(query.limit)
|
|
57
|
+
|
|
58
|
+
total_result = await self.session.execute(count_stmt)
|
|
59
|
+
total_items = total_result.scalar() or 0
|
|
60
|
+
|
|
61
|
+
result = await self.session.execute(base_stmt)
|
|
62
|
+
users = list(result.scalars().all())
|
|
63
|
+
|
|
64
|
+
total_pages = (total_items + query.limit - 1) // query.limit if query.limit > 0 else 0
|
|
65
|
+
|
|
66
|
+
return PaginatedResult(
|
|
67
|
+
data=users,
|
|
68
|
+
meta=PaginationMeta(
|
|
69
|
+
total_items=total_items,
|
|
70
|
+
item_count=len(users),
|
|
71
|
+
items_per_page=query.limit,
|
|
72
|
+
total_pages=total_pages,
|
|
73
|
+
current_page=query.page,
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def soft_delete(self, id: uuid.UUID) -> None:
|
|
78
|
+
"""Soft delete (marca deleted_at)."""
|
|
79
|
+
from datetime import UTC, datetime
|
|
80
|
+
|
|
81
|
+
user = await self.find_by_id(id)
|
|
82
|
+
if user:
|
|
83
|
+
user.deleted_at = datetime.now(UTC)
|
|
84
|
+
await self.session.flush()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, Query, status
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from app.auth.dependencies import get_current_user, require_role
|
|
7
|
+
from app.auth.enums import Role
|
|
8
|
+
from app.core.database import get_db
|
|
9
|
+
from app.user import service as user_service
|
|
10
|
+
from app.user.models import User
|
|
11
|
+
from app.user.schemas import (
|
|
12
|
+
CreateUserRequest,
|
|
13
|
+
QueryUsersParams,
|
|
14
|
+
UpdateUserRequest,
|
|
15
|
+
UserResponse,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/users", tags=["users"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --- Rotas do próprio usuário ---
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get(
|
|
25
|
+
"/me",
|
|
26
|
+
response_model=UserResponse,
|
|
27
|
+
summary="Retorna o perfil do usuário logado",
|
|
28
|
+
responses={200: {"description": "Perfil do usuário."}},
|
|
29
|
+
)
|
|
30
|
+
async def get_me(
|
|
31
|
+
current_user: User = Depends(get_current_user),
|
|
32
|
+
db: AsyncSession = Depends(get_db),
|
|
33
|
+
):
|
|
34
|
+
return await user_service.get_user_by_id(db, current_user.id)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.patch(
|
|
38
|
+
"/me",
|
|
39
|
+
response_model=UserResponse,
|
|
40
|
+
summary="Atualiza o perfil do usuário logado",
|
|
41
|
+
responses={200: {"description": "Perfil atualizado com sucesso."}},
|
|
42
|
+
)
|
|
43
|
+
async def update_me(
|
|
44
|
+
data: UpdateUserRequest,
|
|
45
|
+
current_user: User = Depends(get_current_user),
|
|
46
|
+
db: AsyncSession = Depends(get_db),
|
|
47
|
+
):
|
|
48
|
+
return await user_service.update_user_profile(db, current_user.id, data)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- Rotas administrativas ---
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.post(
|
|
55
|
+
"/",
|
|
56
|
+
response_model=UserResponse,
|
|
57
|
+
status_code=status.HTTP_201_CREATED,
|
|
58
|
+
summary="Cria um novo usuário (admin)",
|
|
59
|
+
responses={201: {"description": "Usuário criado com sucesso."}},
|
|
60
|
+
)
|
|
61
|
+
async def create_user(
|
|
62
|
+
data: CreateUserRequest,
|
|
63
|
+
_admin: User = Depends(require_role(Role.ADMIN)),
|
|
64
|
+
db: AsyncSession = Depends(get_db),
|
|
65
|
+
):
|
|
66
|
+
return await user_service.create_user(db, data)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.get(
|
|
70
|
+
"/",
|
|
71
|
+
response_model=list[UserResponse],
|
|
72
|
+
summary="Busca todos os usuários",
|
|
73
|
+
responses={200: {"description": "Lista de usuários retornada com sucesso."}},
|
|
74
|
+
)
|
|
75
|
+
async def find_all(
|
|
76
|
+
_admin: User = Depends(require_role(Role.ADMIN)),
|
|
77
|
+
db: AsyncSession = Depends(get_db),
|
|
78
|
+
):
|
|
79
|
+
return await user_service.get_all_users(db)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get(
|
|
83
|
+
"/paginated",
|
|
84
|
+
summary="Busca todos os usuários com paginação",
|
|
85
|
+
responses={200: {"description": "Lista de usuários retornada com sucesso."}},
|
|
86
|
+
)
|
|
87
|
+
async def find_paginated(
|
|
88
|
+
page: int = Query(1, ge=1),
|
|
89
|
+
limit: int = Query(10, ge=1, le=100),
|
|
90
|
+
search: str | None = Query(None),
|
|
91
|
+
sort_by: str = Query("id"),
|
|
92
|
+
sort_order: str = Query("ASC"),
|
|
93
|
+
_admin: User = Depends(require_role(Role.ADMIN)),
|
|
94
|
+
db: AsyncSession = Depends(get_db),
|
|
95
|
+
):
|
|
96
|
+
from app.common.schemas import SortOrder as SortOrderEnum
|
|
97
|
+
|
|
98
|
+
query = QueryUsersParams(
|
|
99
|
+
page=page,
|
|
100
|
+
limit=limit,
|
|
101
|
+
search=search,
|
|
102
|
+
sort_by=sort_by,
|
|
103
|
+
sort_order=SortOrderEnum(sort_order.upper()),
|
|
104
|
+
)
|
|
105
|
+
return await user_service.get_users_paginated(db, query)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.get(
|
|
109
|
+
"/{user_id}",
|
|
110
|
+
response_model=UserResponse,
|
|
111
|
+
summary="Busca usuário pelo Id",
|
|
112
|
+
responses={
|
|
113
|
+
200: {"description": "Usuário encontrado."},
|
|
114
|
+
404: {"description": "Usuário não encontrado."},
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
async def find_by_id(
|
|
118
|
+
user_id: uuid.UUID,
|
|
119
|
+
_admin: User = Depends(require_role(Role.ADMIN)),
|
|
120
|
+
db: AsyncSession = Depends(get_db),
|
|
121
|
+
):
|
|
122
|
+
return await user_service.get_user_by_id(db, user_id)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.patch(
|
|
126
|
+
"/{user_id}",
|
|
127
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
128
|
+
summary="Atualiza um usuário pelo ID (admin)",
|
|
129
|
+
responses={204: {"description": "Usuário atualizado com sucesso."}},
|
|
130
|
+
)
|
|
131
|
+
async def update_user(
|
|
132
|
+
user_id: uuid.UUID,
|
|
133
|
+
data: UpdateUserRequest,
|
|
134
|
+
_admin: User = Depends(require_role(Role.ADMIN)),
|
|
135
|
+
db: AsyncSession = Depends(get_db),
|
|
136
|
+
):
|
|
137
|
+
await user_service.update_user_profile(db, user_id, data)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.patch(
|
|
141
|
+
"/{user_id}/reset-password",
|
|
142
|
+
summary="Reseta a senha de um usuário (admin)",
|
|
143
|
+
responses={
|
|
144
|
+
200: {"description": "Senha resetada com sucesso."},
|
|
145
|
+
404: {"description": "Usuário não encontrado."},
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
async def reset_password(
|
|
149
|
+
user_id: uuid.UUID,
|
|
150
|
+
_admin: User = Depends(require_role(Role.ADMIN)),
|
|
151
|
+
db: AsyncSession = Depends(get_db),
|
|
152
|
+
):
|
|
153
|
+
return await user_service.reset_password_by_admin(db, user_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@router.delete(
|
|
157
|
+
"/{user_id}",
|
|
158
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
159
|
+
summary="Deleta um usuário (admin)",
|
|
160
|
+
responses={
|
|
161
|
+
204: {"description": "Usuário deletado com sucesso."},
|
|
162
|
+
404: {"description": "Usuário não encontrado."},
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
async def delete_user(
|
|
166
|
+
user_id: uuid.UUID,
|
|
167
|
+
_admin: User = Depends(require_role(Role.ADMIN)),
|
|
168
|
+
db: AsyncSession = Depends(get_db),
|
|
169
|
+
):
|
|
170
|
+
await user_service.delete_user(db, user_id)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, EmailStr, Field
|
|
4
|
+
|
|
5
|
+
from app.auth.enums import Role
|
|
6
|
+
from app.common.schemas import BaseQueryParams
|
|
7
|
+
|
|
8
|
+
# --- Create ---
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CreateUserRequest(BaseModel):
|
|
12
|
+
"""Schema para criação de usuário."""
|
|
13
|
+
|
|
14
|
+
email: EmailStr
|
|
15
|
+
name: str
|
|
16
|
+
is_active: bool | None = True
|
|
17
|
+
role: Role | None = Role.USER
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --- Update ---
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UpdateUserRequest(BaseModel):
|
|
24
|
+
"""Schema para atualização de usuário."""
|
|
25
|
+
|
|
26
|
+
email: EmailStr | None = None
|
|
27
|
+
name: str | None = None
|
|
28
|
+
phone: str | None = None
|
|
29
|
+
is_active: bool | None = None
|
|
30
|
+
role: Role | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --- Response ---
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UserResponse(BaseModel):
|
|
37
|
+
"""Schema de resposta do usuário (sem password)."""
|
|
38
|
+
|
|
39
|
+
model_config = {"from_attributes": True}
|
|
40
|
+
|
|
41
|
+
id: uuid.UUID
|
|
42
|
+
email: str
|
|
43
|
+
name: str
|
|
44
|
+
phone: str | None = None
|
|
45
|
+
is_active: bool
|
|
46
|
+
role: Role
|
|
47
|
+
must_change_password: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- Query ---
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class QueryUsersParams(BaseQueryParams):
|
|
54
|
+
"""Query params para busca de usuários."""
|
|
55
|
+
|
|
56
|
+
sort_by: str = Field(
|
|
57
|
+
default="id",
|
|
58
|
+
description="Coluna de ordenação.",
|
|
59
|
+
json_schema_extra={"enum": ["id", "name", "email", "is_active", "role"]},
|
|
60
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException, status
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from app.auth.service import hash_password
|
|
7
|
+
from app.common.errors import ERRORS
|
|
8
|
+
from app.common.pagination import PaginatedResult
|
|
9
|
+
from app.core.config import settings
|
|
10
|
+
from app.user.models import User
|
|
11
|
+
from app.user.repository import UserRepository
|
|
12
|
+
from app.user.schemas import CreateUserRequest, QueryUsersParams, UpdateUserRequest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def create_user(db: AsyncSession, data: CreateUserRequest) -> User:
|
|
16
|
+
"""Cria um novo usuário com senha padrão."""
|
|
17
|
+
repo = UserRepository(db)
|
|
18
|
+
|
|
19
|
+
existing = await repo.find_by_email(data.email)
|
|
20
|
+
if existing:
|
|
21
|
+
raise HTTPException(
|
|
22
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
23
|
+
detail=f"{ERRORS['USER']['EMAIL_IN_USE']} (Email: {data.email})",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
default_password = settings.DEFAULT_PASSWORD
|
|
27
|
+
if not default_password:
|
|
28
|
+
raise HTTPException(
|
|
29
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
30
|
+
detail=ERRORS["USER"]["DEFAULT_PASSWORD_NOT_SET"],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
hashed = hash_password(default_password)
|
|
34
|
+
|
|
35
|
+
return await repo.create({
|
|
36
|
+
**data.model_dump(exclude_unset=True),
|
|
37
|
+
"password": hashed,
|
|
38
|
+
"must_change_password": True,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def get_user_by_id(db: AsyncSession, user_id: uuid.UUID) -> User:
|
|
43
|
+
"""Busca um usuário pelo ID, levantando 404 se não encontrado."""
|
|
44
|
+
repo = UserRepository(db)
|
|
45
|
+
user = await repo.find_by_id(user_id)
|
|
46
|
+
if not user:
|
|
47
|
+
raise HTTPException(
|
|
48
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
49
|
+
detail=ERRORS["USER"]["NOT_FOUND"],
|
|
50
|
+
)
|
|
51
|
+
return user
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def get_all_users(db: AsyncSession) -> list[User]:
|
|
55
|
+
"""Lista todos os usuários ativos."""
|
|
56
|
+
repo = UserRepository(db)
|
|
57
|
+
return await repo.find_all()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def get_users_paginated(db: AsyncSession, query: QueryUsersParams) -> PaginatedResult[User]:
|
|
61
|
+
"""Lista usuários com paginação e busca."""
|
|
62
|
+
repo = UserRepository(db)
|
|
63
|
+
return await repo.find_and_count_users(query)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def update_user_profile(
|
|
67
|
+
db: AsyncSession, user_id: uuid.UUID, data: UpdateUserRequest
|
|
68
|
+
) -> User:
|
|
69
|
+
"""Atualiza o perfil de um usuário."""
|
|
70
|
+
repo = UserRepository(db)
|
|
71
|
+
user = await repo.find_by_id(user_id)
|
|
72
|
+
if not user:
|
|
73
|
+
raise HTTPException(
|
|
74
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
75
|
+
detail=ERRORS["USER"]["NOT_FOUND"],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
update_data = data.model_dump(exclude_unset=True)
|
|
79
|
+
for key, value in update_data.items():
|
|
80
|
+
setattr(user, key, value)
|
|
81
|
+
|
|
82
|
+
await db.flush()
|
|
83
|
+
await db.refresh(user)
|
|
84
|
+
return user
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def reset_password_by_admin(db: AsyncSession, user_id: uuid.UUID) -> dict:
|
|
88
|
+
"""Reseta a senha de um usuário para a senha padrão."""
|
|
89
|
+
user = await get_user_by_id(db, user_id)
|
|
90
|
+
|
|
91
|
+
default_password = settings.DEFAULT_PASSWORD
|
|
92
|
+
if not default_password:
|
|
93
|
+
raise HTTPException(
|
|
94
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
95
|
+
detail=ERRORS["USER"]["DEFAULT_PASSWORD_NOT_SET"],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
user.password = hash_password(default_password)
|
|
99
|
+
user.must_change_password = True
|
|
100
|
+
await db.flush()
|
|
101
|
+
|
|
102
|
+
return {"message": f"A senha do usuário {user.name} foi resetada com sucesso"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def delete_user(db: AsyncSession, user_id: uuid.UUID) -> None:
|
|
106
|
+
"""Soft delete de um usuário."""
|
|
107
|
+
repo = UserRepository(db)
|
|
108
|
+
user = await repo.find_by_id(user_id)
|
|
109
|
+
if not user:
|
|
110
|
+
raise HTTPException(
|
|
111
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
112
|
+
detail=ERRORS["USER"]["NOT_FOUND"],
|
|
113
|
+
)
|
|
114
|
+
await repo.soft_delete(user_id)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
version: "3.9"
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
api-prod:
|
|
5
|
+
container_name: api_prod
|
|
6
|
+
build:
|
|
7
|
+
context: .
|
|
8
|
+
dockerfile: Dockerfile
|
|
9
|
+
target: production
|
|
10
|
+
ports:
|
|
11
|
+
- "3000:3000"
|
|
12
|
+
env_file:
|
|
13
|
+
- .env
|
|
14
|
+
depends_on:
|
|
15
|
+
postgres:
|
|
16
|
+
condition: service_healthy
|
|
17
|
+
restart: unless-stopped
|
|
18
|
+
profiles: ["prod"]
|
|
19
|
+
|
|
20
|
+
api-dev:
|
|
21
|
+
container_name: api_dev
|
|
22
|
+
build:
|
|
23
|
+
context: .
|
|
24
|
+
dockerfile: Dockerfile
|
|
25
|
+
target: development
|
|
26
|
+
ports:
|
|
27
|
+
- "3000:3000"
|
|
28
|
+
env_file:
|
|
29
|
+
- .env
|
|
30
|
+
depends_on:
|
|
31
|
+
postgres:
|
|
32
|
+
condition: service_healthy
|
|
33
|
+
volumes:
|
|
34
|
+
- .:/app
|
|
35
|
+
restart: unless-stopped
|
|
36
|
+
profiles: ["dev"]
|
|
37
|
+
|
|
38
|
+
postgres:
|
|
39
|
+
image: postgres:15
|
|
40
|
+
container_name: postgres
|
|
41
|
+
restart: unless-stopped
|
|
42
|
+
healthcheck:
|
|
43
|
+
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
|
44
|
+
interval: 10s
|
|
45
|
+
timeout: 5s
|
|
46
|
+
retries: 5
|
|
47
|
+
env_file:
|
|
48
|
+
- .env
|
|
49
|
+
ports:
|
|
50
|
+
- "5432:5432"
|
|
51
|
+
volumes:
|
|
52
|
+
- db_data:/var/lib/postgresql/data
|
|
53
|
+
|
|
54
|
+
volumes:
|
|
55
|
+
db_data:
|