diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 77561ea..5b31cb4 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -8,12 +8,34 @@ permissions: contents: read jobs: + quality-gates: + name: Formatting and Linting + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install pip dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Black check + run: | + black --check webapi/schemas webapi/tests/functional/test_auth_routes.py + - name: Pylint check + run: | + pylint webapi/schemas + cd-tests: name: Python Tests runs-on: ubuntu-latest + needs: quality-gates steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f629665..45c57d8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,9 +8,31 @@ on: workflow_dispatch: jobs: + quality-gates: + name: Formatting and Linting + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install pip dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Black check + run: | + black --check webapi/schemas webapi/tests/functional/test_auth_routes.py + - name: Pylint check + run: | + pylint webapi/schemas + ci-tests: name: Python Tests runs-on: ubuntu-latest + needs: quality-gates steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index a00fae1..7dba06a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,9 @@ __pycache__/ .coverage coverage.xml htmlcov/ + +# VSCode +.vscode/ + +# Environment variables +.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..780f6d8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-merge-conflict + + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + language_version: python3.12 + + - repo: local + hooks: + - id: pylint + name: pylint + entry: pylint + language: system + types: [python] + pass_filenames: true diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..c49aee2 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,19 @@ +[MASTER] +ignore=.git,.venv,build,dist +jobs=0 + +[MESSAGES CONTROL] +disable= + missing-module-docstring, + missing-function-docstring, + missing-class-docstring, + too-few-public-methods + +[FORMAT] +max-line-length=100 + +[DESIGN] +max-args=10 + +[TYPECHECK] +ignored-modules=sqlmodel diff --git a/README.md b/README.md index 5842207..a5aa90a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,56 @@ Build and run the app: docker build -t webapi:dev .; docker run --rm -p 8000:8000 webapi:dev ``` +## Development tooling and quality gates + +This project now includes recruiter-relevant backend tooling practices: + +- **Formatter:** Black (configured in [`pyproject.toml`](pyproject.toml)) +- **Linter:** Pylint (configured in [`.pylintrc`](.pylintrc)) +- **Git hooks:** pre-commit (configured in [`.pre-commit-config.yaml`](.pre-commit-config.yaml)) +- **Validation:** Pydantic constraints in schemas under [`webapi/schemas`](webapi/schemas) +- **CI quality gates:** formatting and lint checks in [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml) and [`.github/workflows/cd.yml`](.github/workflows/cd.yml) + +### Local setup + +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +Install git hooks: + +```bash +pre-commit install +``` + +### Run tooling locally + +Run Black formatting: + +```bash +black webapi +``` + +Run Pylint checks: + +```bash +pylint webapi/schemas +``` + +Run pre-commit on all files: + +```bash +pre-commit run --all-files +``` + +Run validation-focused tests: + +```bash +pytest webapi/tests/functional/test_auth_routes.py -k validation +``` + ## Database diagram documentation The ORM model and relational mapping documentation for dbdiagram.io is available at [`plans/dbdiagram.md`](plans/dbdiagram.md). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ccf0b6c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.black] +line-length = 100 +target-version = ["py312"] +include = '\\.pyi?$' +extend-exclude = ''' +/(\ + \.git + | \.venv + | build + | dist +)/ +''' + +[tool.pytest.ini_options] +testpaths = ["webapi/tests"] +python_files = ["test_*.py"] +addopts = "-q" diff --git a/requirements.txt b/requirements.txt index 0e4a41c..7507da6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,7 @@ python-dotenv fastapi-mail>=1.4.1 redis~=5.3.1 pytest~=8.4.2 -httpx~=0.28.1 \ No newline at end of file +httpx~=0.28.1 +black~=24.10.0 +pylint~=3.3.1 +pre-commit~=4.0.1 diff --git a/webapi/api/endpoints/v1/auths.py b/webapi/api/endpoints/v1/auths.py index b3cc367..e5e6b7e 100644 --- a/webapi/api/endpoints/v1/auths.py +++ b/webapi/api/endpoints/v1/auths.py @@ -31,14 +31,13 @@ def signup(user: User, session: Session = Depends(get_session)): session.refresh(user) return {"message": "User created successfully"} + @router.post("/login") def login(request: LoginRequest, session: Session = Depends(get_session)): user = authenticate_user(request.username, request.password, session) if not user: raise HTTPException(status_code=401, detail="Incorrect username or password") - access_token = crear_jwt( - data={"sub": user.username} - ) + access_token = crear_jwt(data={"sub": user.username}) return {"access_token": access_token, "token_type": "bearer"} @@ -46,12 +45,13 @@ def login(request: LoginRequest, session: Session = Depends(get_session)): def profile(current_user: dict = Depends(get_current_user)): return {"profile data": current_user} + @router.post("/generate") async def generate_password( username: str, ttl: int = 300, redis: Redis = Depends(get_redis), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): statement = select(User).where(User.username == username) result = session.exec(statement) @@ -60,7 +60,7 @@ async def generate_password( raise HTTPException(status_code=404, detail="User not found") """Generate random key""" - encoded = base64.b64encode(username.encode('utf-8')).decode('utf-8') + encoded = base64.b64encode(username.encode("utf-8")).decode("utf-8") key = f"{secrets.token_hex(16)}.{encoded}" if redis.exists(key): raise HTTPException(status_code=400, detail="Key already exists") @@ -70,17 +70,18 @@ async def generate_password( session.add(user) session.commit() session.refresh(user) - email_body = f"Hey {user.username} this is your recovery key:\n--> {key} <--\nit expires in {ttl/60}" + email_body = ( + f"Hey {user.username} this is your recovery key:\n--> {key} <--\nit expires in {ttl/60}" + ) await send_email(user.email, user.username, email_body) redis.setex(key, ttl, password) return {f"Message sent successfully, it expires in {ttl/60} minutes"} + @router.post("/recover") def recover_password( - key: str, - redis: Redis = Depends(get_redis), - session: Session = Depends(get_session) + key: str, redis: Redis = Depends(get_redis), session: Session = Depends(get_session) ): match = re.search(r"\.(.+)$", key) @@ -94,7 +95,7 @@ def recover_password( if missing_padding: username += "=" * (4 - missing_padding) try: - username = base64.b64decode(username).decode('utf-8') + username = base64.b64decode(username).decode("utf-8") except (binascii.Error, UnicodeDecodeError) as e: raise HTTPException(status_code=401, detail=f"Decode error: {e}") statement = select(User).where(User.username == username) diff --git a/webapi/api/endpoints/v1/prompts.py b/webapi/api/endpoints/v1/prompts.py index 00d777c..00846b8 100644 --- a/webapi/api/endpoints/v1/prompts.py +++ b/webapi/api/endpoints/v1/prompts.py @@ -9,12 +9,13 @@ router = APIRouter() + @router.post("", response_model=Prompts) async def create_prompt( prompt: Prompts, session: Session = Depends(get_session), current_user: dict = Depends(get_current_user), - send_email_header: Optional[str] = Header("false", alias="send_email") + send_email_header: Optional[str] = Header("false", alias="send_email"), ): # Ensure the user exists before creating a prompt user = session.get(User, prompt.user_id) @@ -34,10 +35,14 @@ async def create_prompt( return prompt + @router.get("", response_model=list[Prompts]) -def read_prompts(skip: int = 0, limit: int = 10, - session: Session = Depends(get_session), - current_user: dict = Depends(get_current_user)): +def read_prompts( + skip: int = 0, + limit: int = 10, + session: Session = Depends(get_session), + current_user: dict = Depends(get_current_user), +): statement = select(Prompts).offset(skip).limit(limit) prompts = session.exec(statement).all() if not prompts: @@ -46,17 +51,24 @@ def read_prompts(skip: int = 0, limit: int = 10, @router.get("/{prompt_id}", response_model=Prompts) -def get_prompt(prompt_id: int, session: Session = Depends(get_session), - current_user: dict = Depends(get_current_user)): +def get_prompt( + prompt_id: int, + session: Session = Depends(get_session), + current_user: dict = Depends(get_current_user), +): prompt = session.get(Prompts, prompt_id) if not prompt: raise HTTPException(status_code=404, detail="Prompt not found") return prompt + @router.put("/{prompt_id}", response_model=Prompts) -def update_prompt(prompt_id: int, prompt: Prompts, - session: Session = Depends(get_session), - current_user: dict = Depends(get_current_user)): +def update_prompt( + prompt_id: int, + prompt: Prompts, + session: Session = Depends(get_session), + current_user: dict = Depends(get_current_user), +): existing_prompt = session.get(Prompts, prompt_id) if not existing_prompt: raise HTTPException(status_code=404, detail="Prompt not found") @@ -75,8 +87,11 @@ def update_prompt(prompt_id: int, prompt: Prompts, @router.delete("/{prompt_id}") -def delete_prompt(prompt_id: int, session: Session = Depends(get_session), - current_user: dict = Depends(get_current_user)): +def delete_prompt( + prompt_id: int, + session: Session = Depends(get_session), + current_user: dict = Depends(get_current_user), +): prompt = session.get(Prompts, prompt_id) if not prompt: raise HTTPException(status_code=404, detail="Prompt not found to delete") diff --git a/webapi/api/endpoints/v1/users.py b/webapi/api/endpoints/v1/users.py index eff8518..e4b0dec 100644 --- a/webapi/api/endpoints/v1/users.py +++ b/webapi/api/endpoints/v1/users.py @@ -10,12 +10,17 @@ router = APIRouter() + # curl -X POST "http://localhost:8000/login?username=lfponcen&password=lfponcen" -v <-- to get the session_token thru the cookie # curl -b session_token=(gotten from verbose) -X GET "http://localhost:8000" @router.get("", response_model=list[User]) -def read_users(phone: Optional[int] = None, skip: int = 0, limit: int = 10, - session: Session = Depends(get_session), - current_user: dict = Depends(get_current_user)): +def read_users( + phone: Optional[int] = None, + skip: int = 0, + limit: int = 10, + session: Session = Depends(get_session), + current_user: dict = Depends(get_current_user), +): statement = select(User).offset(skip).limit(limit) # If phone is provided, filter by phone number if phone: @@ -27,8 +32,11 @@ def read_users(phone: Optional[int] = None, skip: int = 0, limit: int = 10, @router.get("/{user_id}", response_model=User) -def get_user(user_id: int, session: Session = Depends(get_session), - current_user: dict = Depends(get_current_user)): +def get_user( + user_id: int, + session: Session = Depends(get_session), + current_user: dict = Depends(get_current_user), +): user = session.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") @@ -36,8 +44,11 @@ def get_user(user_id: int, session: Session = Depends(get_session), @router.get("/prompts/{user_id}", response_model=UserReadWithPrompts) -def get_user_with_prompts(user_id: int, session: Session = Depends(get_session), - current_user: dict = Depends(get_current_user)): +def get_user_with_prompts( + user_id: int, + session: Session = Depends(get_session), + current_user: dict = Depends(get_current_user), +): statement = select(User).where(User.id == user_id) result = session.exec(statement) user = result.one_or_none() @@ -47,10 +58,14 @@ def get_user_with_prompts(user_id: int, session: Session = Depends(get_session), _ = user.prompts return user + @router.put("/{user_id}", response_model=User) -def update_user(user_id: int, user: User, - session: Session = Depends(get_session), - current_user: dict = Depends(get_current_user)): +def update_user( + user_id: int, + user: User, + session: Session = Depends(get_session), + current_user: dict = Depends(get_current_user), +): user.name = user.name.lower() user.last_name = user.last_name.lower() existing_user = session.get(User, user_id) @@ -72,8 +87,11 @@ def update_user(user_id: int, user: User, @router.delete("/{user_id}") -def delete_user(user_id: int, session: Session = Depends(get_session), - current_user: dict = Depends(get_current_user)): +def delete_user( + user_id: int, + session: Session = Depends(get_session), + current_user: dict = Depends(get_current_user), +): try: user = session.get(User, user_id) if not user: diff --git a/webapi/auth/auth_service.py b/webapi/auth/auth_service.py index 4b9c6fa..4c4ec81 100644 --- a/webapi/auth/auth_service.py +++ b/webapi/auth/auth_service.py @@ -38,11 +38,12 @@ def crear_jwt(data: dict): payload = { "exp": datetime.utcnow() + timedelta(hours=1), "iat": datetime.utcnow(), - "data": data + "data": data, } token = jwt.encode(payload, SECRET_KEY, algorithm="HS256") return token + # Validar un token con prefijo "Bearer" (compatibilidad legado) def validar_jwt(token: str): try: @@ -91,4 +92,3 @@ def get_current_user( if not data: raise HTTPException(status_code=401, detail="Unauthorized token") return data - \ No newline at end of file diff --git a/webapi/core/config.py b/webapi/core/config.py index d50a489..a66cf61 100644 --- a/webapi/core/config.py +++ b/webapi/core/config.py @@ -11,16 +11,16 @@ # SMTP server configuration try: smtp_conf = ConnectionConfig( - MAIL_USERNAME=os.getenv("ENV_MAIL_USERNAME",settings.ENV_MAIL_USERNAME), - MAIL_PASSWORD=os.getenv("ENV_MAIL_PASSWORD",settings.ENV_MAIL_PASSWORD), - MAIL_FROM=os.getenv("ENV_MAIL_USERNAME",settings.ENV_MAIL_FROM), + MAIL_USERNAME=os.getenv("ENV_MAIL_USERNAME", settings.ENV_MAIL_USERNAME), + MAIL_PASSWORD=os.getenv("ENV_MAIL_PASSWORD", settings.ENV_MAIL_PASSWORD), + MAIL_FROM=os.getenv("ENV_MAIL_USERNAME", settings.ENV_MAIL_FROM), MAIL_FROM_NAME="Your App", MAIL_PORT=587, MAIL_SERVER="smtp.gmail.com", MAIL_STARTTLS=True, MAIL_SSL_TLS=False, USE_CREDENTIALS=True, - VALIDATE_CERTS=True + VALIDATE_CERTS=True, ) except ValidationError as e: print("⚠️ Missing mail config in environment; skipping email setup for tests.") @@ -44,4 +44,4 @@ DB_URL = os.getenv("DB_URL") if not DB_URL: - DB_URL = "sqlite:///./bank_db.db" # Default to SQLite if no environment variable is set \ No newline at end of file + DB_URL = "sqlite:///./bank_db.db" # Default to SQLite if no environment variable is set diff --git a/webapi/db/db_connection.py b/webapi/db/db_connection.py index b74efa8..fcf12c4 100644 --- a/webapi/db/db_connection.py +++ b/webapi/db/db_connection.py @@ -1,5 +1,6 @@ from sqlmodel import SQLModel, create_engine, Session from core import config + # from sqlalchemy.ext.asyncio import AsyncSession connect_args = {"check_same_thread": False} @@ -12,4 +13,4 @@ def get_session(): with Session(engine) as session: - yield session \ No newline at end of file + yield session diff --git a/webapi/db/redis_connection.py b/webapi/db/redis_connection.py index f2d8863..a8aec91 100644 --- a/webapi/db/redis_connection.py +++ b/webapi/db/redis_connection.py @@ -8,14 +8,15 @@ redis/redis-stack:latest """ + def get_redis(): redis = Redis( host=config.REDIS_HOST, port=config.REDIS_PORT, password=config.REDIS_PSW, - decode_responses=config.REDIS_DECODE_RESP + decode_responses=config.REDIS_DECODE_RESP, ) try: yield redis finally: - redis.close() \ No newline at end of file + redis.close() diff --git a/webapi/infrastructure/email/smtp_service.py b/webapi/infrastructure/email/smtp_service.py index 53736d7..73b3756 100644 --- a/webapi/infrastructure/email/smtp_service.py +++ b/webapi/infrastructure/email/smtp_service.py @@ -3,21 +3,21 @@ from pydantic import EmailStr from core.config import smtp_conf -async def send_email(to: EmailStr, username: str, message_body : str = ""): + +async def send_email(to: EmailStr, username: str, message_body: str = ""): if message_body == "": message_body = f"Hello {username}, welcome to LuisLearning App!" try: message = MessageSchema( - subject = "LuisLearning App notify module", - recipients = [to], - body = message_body, - subtype = MessageType.html, + subject="LuisLearning App notify module", + recipients=[to], + body=message_body, + subtype=MessageType.html, ) # Send the email using FastMail fm = FastMail(smtp_conf) await fm.send_message(message, template_name="welcome.html") - + return {"message": "Email sent successfully"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - diff --git a/webapi/infrastructure/email/templates/welcome.html b/webapi/infrastructure/email/templates/welcome.html index aa8ab2e..0ac04b5 100644 --- a/webapi/infrastructure/email/templates/welcome.html +++ b/webapi/infrastructure/email/templates/welcome.html @@ -8,4 +8,4 @@
Thanks for registering your card at Bank App. We’re happy to have you.