diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e3817b6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,45 @@ +## Summary + + +## Type of Change + +- [ ] Feature +- [ ] Fix +- [ ] Improvement +- [ ] Refactor +- [ ] Documentation + +## Included Changes + + +### Features + +- + +### Fixes + +- + +### Improvements + +- + +## Related Issues + + + +## Testing + +- [ ] Unit tests +- [ ] Integration tests +- [ ] Manual testing +- [ ] N/A + +## Notes + +- + +## Checklist +- [ ] Self-review completed +- [ ] CI passing +- [ ] Ready to merge diff --git a/.github/workflows/backend-ci.yaml b/.github/workflows/backend-ci.yaml index 6f2d14a..d4a9d41 100644 --- a/.github/workflows/backend-ci.yaml +++ b/.github/workflows/backend-ci.yaml @@ -67,5 +67,5 @@ jobs: with: name: coverage-reports path: | - coverage.xml - htmlcov/ + backend/coverage.xml + backend/htmlcov/ \ No newline at end of file diff --git a/backend/app/application/dto/application.py b/backend/app/application/dto/application.py index 0707f53..491a00b 100644 --- a/backend/app/application/dto/application.py +++ b/backend/app/application/dto/application.py @@ -1,6 +1,6 @@ from datetime import date -from pydantic import BaseModel +from pydantic import BaseModel, HttpUrl from typing_extensions import Literal from app.application.dto import BaseSchema @@ -13,6 +13,7 @@ class ApplicationCreateDTO(BaseModel): mode: Literal['active', 'passive'] platform_id: int application_date: date + link_to_job: HttpUrl | None observation: str | None = None expected_salary: float | None = None salary_range_min: float | None = None @@ -26,6 +27,7 @@ class ApplicationUpdateDTO(BaseModel): mode: Literal['active', 'passive'] platform_id: int application_date: date + link_to_job: HttpUrl | None observation: str | None = None expected_salary: float | None = None salary_range_min: float | None = None @@ -60,6 +62,7 @@ class ApplicationDTO(BaseSchema): mode: Literal['active', 'passive'] platform_id: int application_date: date + link_to_job: HttpUrl | None = None observation: str | None = None expected_salary: float | None = None salary_range_min: float | None = None diff --git a/backend/app/application/use_cases/applications/list_applications.py b/backend/app/application/use_cases/applications/list_applications.py index efa0dae..0f06863 100644 --- a/backend/app/application/use_cases/applications/list_applications.py +++ b/backend/app/application/use_cases/applications/list_applications.py @@ -12,4 +12,11 @@ def __init__(self, app_repo: ApplicationRepository): async def execute(self, user_id: int) -> List[ApplicationDTO]: applications = await self.app_repo.get_all_by_user_id(user_id) - return [ApplicationDTO.model_validate(app) for app in applications] + active, finalized = [], [] + for application in applications: + application = ApplicationDTO.model_validate(application) + if application.feedback is None: + active.append(application) + else: + finalized.append(application) + return active + finalized diff --git a/backend/app/application/use_cases/applications/update_application.py b/backend/app/application/use_cases/applications/update_application.py index 99d91db..a77f500 100644 --- a/backend/app/application/use_cases/applications/update_application.py +++ b/backend/app/application/use_cases/applications/update_application.py @@ -39,6 +39,8 @@ async def execute( application.mode = data.mode application.platform_id = data.platform_id application.application_date = data.application_date + application.link_to_job = (str(data.link_to_job) + if data.link_to_job else None) application.observation = data.observation application.expected_salary = data.expected_salary application.salary_range_min = data.salary_range_min diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index b7b2e37..e6db1e9 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -59,5 +59,11 @@ class Settings(BaseSettings): LOGIN_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/docs' + @property + def openapi_url(self): + if self.ENVIRONMENT == "PROD": + return None + return "/openapi.json" + envs = Settings() diff --git a/backend/app/domain/models.py b/backend/app/domain/models.py index d54f46b..ec6949d 100644 --- a/backend/app/domain/models.py +++ b/backend/app/domain/models.py @@ -15,10 +15,12 @@ class BaseMixin: sa.DateTime(timezone=True), default=sa.func.now(), nullable=False ) updated_at: Mapped[datetime] = mapped_column( - sa.DateTime(timezone=True), onupdate=sa.func.now(), nullable=True + sa.DateTime(timezone=True), default=None, + onupdate=sa.func.now(), nullable=True ) +# TODO: Configure MappedAsDataclass and set default_factory for each model field class Base(DeclarativeBase): ... @@ -161,6 +163,7 @@ class ApplicationModel(BaseMixin, Base): sa.String(10), nullable=False ) observation: Mapped[Optional[str]] = mapped_column(sa.Text) + link_to_job: Mapped[Optional[str]] = mapped_column(sa.String(2083)) salary_offer: Mapped[Optional[float]] = mapped_column(sa.Numeric(10, 2)) expected_salary: Mapped[Optional[float]] = mapped_column(sa.Numeric(10, 2)) diff --git a/backend/app/domain/repositories/application_repository.py b/backend/app/domain/repositories/application_repository.py index a67dcf0..211e2ed 100644 --- a/backend/app/domain/repositories/application_repository.py +++ b/backend/app/domain/repositories/application_repository.py @@ -42,7 +42,11 @@ async def create( self, application: ApplicationCreateDTO ) -> ApplicationModel: try: - db_application = ApplicationModel(**application.model_dump()) + db_application = ApplicationModel( + **application.model_dump(exclude={'link_to_job'}), + link_to_job=(str(application.link_to_job) + if application.link_to_job else None), + ) self.session.add(db_application) await self.session.commit() await self.session.refresh(db_application) diff --git a/backend/app/main.py b/backend/app/main.py index 22a646d..ab14705 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ title='Application Panel', version='0.1.0', root_path=envs.API_PREFIX, + openapi_url=envs.openapi_url, ) diff --git a/backend/app/presentation/schemas/application.py b/backend/app/presentation/schemas/application.py index 30eb476..bbcaeb9 100644 --- a/backend/app/presentation/schemas/application.py +++ b/backend/app/presentation/schemas/application.py @@ -1,6 +1,6 @@ from datetime import date -from pydantic import BaseModel +from pydantic import BaseModel, HttpUrl from typing_extensions import Literal from app.presentation.schemas import BaseSchema, TimeSchema @@ -12,6 +12,7 @@ class CreateApplication(BaseSchema): mode: Literal['active', 'passive'] platform_id: int application_date: date + link_to_job: HttpUrl | None = None observation: str | None = None expected_salary: float | None = None salary_range_min: float | None = None @@ -24,6 +25,7 @@ class UpdateApplication(BaseModel): mode: Literal['active', 'passive'] platform_id: int application_date: date + link_to_job: HttpUrl | None = None observation: str | None = None expected_salary: float | None = None salary_range_min: float | None = None @@ -51,6 +53,7 @@ class Application(BaseSchema, TimeSchema): mode: Literal['active', 'passive'] platform_id: int application_date: date + link_to_job: HttpUrl | None = None observation: str | None = None expected_salary: float | None = None salary_range_min: float | None = None diff --git a/backend/app/tests/__init__.py b/backend/app/tests/__init__.py index e69de29..d77bf8f 100644 --- a/backend/app/tests/__init__.py +++ b/backend/app/tests/__init__.py @@ -0,0 +1,2 @@ +def msg(expected, actual): + return f"Expected {expected} but got {actual}" diff --git a/backend/app/tests/base_db_setup.py b/backend/app/tests/base_db_setup.py new file mode 100644 index 0000000..518095c --- /dev/null +++ b/backend/app/tests/base_db_setup.py @@ -0,0 +1,23 @@ +from typing import TypedDict + +from app.domain.models import FeedbackDefinitionModel, PlatformModel, UserModel + + +class BaseDataType(TypedDict): + user: UserModel + plat_linkedin: PlatformModel + fb_denied: FeedbackDefinitionModel + + +def base_data() -> BaseDataType: + return { + 'user': UserModel( + id=1, github_id=1, username="testuser", email="test@user.com" + ), + 'plat_linkedin': PlatformModel( + id=1, name="Linkedin", url="https://www.linkedin.com/" + ), + 'fb_denied': FeedbackDefinitionModel( + id=1, name="Denied", color="#a80000" + ), + } diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 2927829..11c60bc 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -1,7 +1,9 @@ +from datetime import datetime, timezone + import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import ( - AsyncSession, + AsyncEngine, async_sessionmaker, create_async_engine, ) @@ -10,49 +12,76 @@ from app.config.db import get_session from app.domain.models import Base from app.main import app as main_app +from app.presentation.dependencies import get_current_user +from app.tests.base_db_setup import base_data @pytest_asyncio.fixture(scope="session") async def db_container(): - container = PostgresContainer('postgres:14', driver='asyncpg') + container = PostgresContainer("postgres:14", driver="asyncpg") container.start() yield container container.stop() @pytest_asyncio.fixture -async def db_session(db_container): +async def async_engine(db_container: PostgresContainer): db_url = db_container.get_connection_url().replace( "postgresql://", "postgresql+asyncpg://" ) - async_engine = create_async_engine(db_url, echo=False, future=True) + async_engine = create_async_engine(db_url, echo=False) - # Reset database tables before each test async with async_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) - async_session_maker = async_sessionmaker( - async_engine, autoflush=False, expire_on_commit=False + yield async_engine + await async_engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(async_engine: AsyncEngine): + SessionLocal = async_sessionmaker( + bind=async_engine, + expire_on_commit=False, ) - async with async_session_maker() as session: + + async with SessionLocal() as session: + session.add_all(base_data().values()) + await session.commit() yield session @pytest_asyncio.fixture -async def async_client(db_session: AsyncSession): - """ - Async HTTP client for the FastAPI app, using the test DB session. - """ - # Dependency override to inject the test session +async def async_client(async_engine: AsyncEngine): + SessionLocal = async_sessionmaker( + bind=async_engine, + expire_on_commit=False, + ) + async def override_get_session(): - yield db_session + async with SessionLocal() as session: + yield session main_app.dependency_overrides[get_session] = override_get_session + async def override_get_current_user(): + from app.application.dto.user import UserDTO + + return UserDTO( + id=base_data()["user"].id, + github_id=base_data()["user"].github_id, + username=base_data()["user"].username, + email=base_data()["user"].email, + created_at=datetime.now(timezone.utc), + ) + + main_app.dependency_overrides[get_current_user] = override_get_current_user + transport = ASGITransport(app=main_app) - async with AsyncClient(transport=transport, - base_url="http://test/api") as client: + async with AsyncClient( + transport=transport, base_url="http://test/api" + ) as client: yield client main_app.dependency_overrides.clear() diff --git a/backend/app/tests/integration/test_applications.py b/backend/app/tests/integration/test_applications.py new file mode 100644 index 0000000..4b0bcf6 --- /dev/null +++ b/backend/app/tests/integration/test_applications.py @@ -0,0 +1,108 @@ +from datetime import date + +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import ApplicationModel +from app.tests import msg +from app.tests.base_db_setup import base_data + + +async def test_create_application( + async_client: AsyncClient, db_session: AsyncSession): + # Arrange: prepare test data + payload = { + "company": "Applika Inc", + "role": "Software Engineer", + "mode": "active", + "platform_id": base_data()["plat_linkedin"].id, + "application_date": "2025-12-01", + "link_to_job": "https://example.com/job/1", + "observation": "Applied via referral", + "expected_salary": 85000.0, + "salary_range_min": 80000.0, + "salary_range_max": 90000.0 + } + + # Act: call the endpoint + response = await async_client.post("/applications", json=payload) + + # Assert: verify the response + assert response.status_code == 201, msg(201, response.status_code) + data = response.json() + assert data["company"] == payload["company"], \ + msg(payload["company"], data["company"]) + assert data["role"] == payload["role"], \ + msg(payload["role"], data["role"]) + assert data["link_to_job"] == payload["link_to_job"], \ + msg(payload["link_to_job"], data["link_to_job"]) + + +async def test_list_applications( + async_client: AsyncClient, db_session: AsyncSession): + # Arrange: create test data + db_session.add_all([ + ApplicationModel( + id=1, + user_id=base_data()["user"].id, + platform_id=base_data()["fb_denied"].id, + company="Applika Inc", + role="Software Engineer", + mode="active", + application_date=date(2025, 12, 1), + feedback_id=1, + feedback_date=date(2025, 12, 2) + ), + ApplicationModel( + id=2, + user_id=base_data()["user"].id, + platform_id=base_data()["fb_denied"].id, + company="Applika Inc", + role="Fullstack Engineer", + mode="active", + application_date=date(2025, 12, 12) + ), + ]) + await db_session.commit() + + # Act: call the endpoint + response = await async_client.get("/applications") + + # Assert: verify the response + assert response.status_code == 200, msg(200, response.status_code) + data = response.json() + assert isinstance(data, list), msg("list", type(data)) + assert len(data) == 2, msg(2, len(data)) + # Active application first + assert data[0]["id"] == 2, msg(2, data[0]["id"]) + # Finalized application second + assert data[1]["id"] == 1, msg(1, data[1]["id"]) + + +async def test_delete_application( + async_client: AsyncClient, db_session: AsyncSession): + # Arrange: create test data + application = ApplicationModel( + id=1, + user_id=base_data()["user"].id, + platform_id=base_data()["fb_denied"].id, + company="Applika Inc", + role="Software Engineer", + mode="active", + application_date=date(2025, 12, 1) + ) + db_session.add(application) + await db_session.commit() + + # Act: call the endpoint + response = await async_client.delete("/applications/1") + + # Assert: verify the response + assert response.status_code == 204, msg(204, response.status_code) + + # Ensure the session expires cached state so we read fresh data from the DB + await db_session.run_sync(lambda s: s.expire_all()) + + # Verify the application is deleted + deleted_application = await db_session.get(ApplicationModel, 1) + assert deleted_application is None, msg("None", deleted_application) diff --git a/backend/app/tests/integration/test_i.py b/backend/app/tests/integration/test_i.py deleted file mode 100644 index 1afe026..0000000 --- a/backend/app/tests/integration/test_i.py +++ /dev/null @@ -1,2 +0,0 @@ -async def test_integration_placeholder(async_client): - assert True diff --git a/backend/migrations/env.py b/backend/migrations/env.py index bb0281f..dae0514 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -93,3 +93,17 @@ def run_migrations_online() -> None: run_migrations_offline() else: run_migrations_online() + + +# if context.is_offline_mode(): +# run_migrations_offline() +# else: +# try: +# loop = asyncio.get_running_loop() +# except RuntimeError: +# # CLI mode +# asyncio.run(run_async_migrations()) +# else: +# # pytest_asyncio / FastAPI +# print('Running migrations in existing event loop') +# loop.create_task(run_async_migrations()) diff --git a/backend/migrations/versions/d997fc17b3d3_applications_link_to_job.py b/backend/migrations/versions/d997fc17b3d3_applications_link_to_job.py new file mode 100644 index 0000000..3d4469e --- /dev/null +++ b/backend/migrations/versions/d997fc17b3d3_applications_link_to_job.py @@ -0,0 +1,67 @@ +"""''link_to_job' column added to 'applications' table' + +Revision ID: d997fc17b3d3 +Revises: 07d97858223f +Create Date: 2025-12-18 17:11:23.404757 + +""" + +from datetime import datetime, timezone +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd997fc17b3d3' +down_revision: Union[str, Sequence[str], None] = '07d97858223f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + now = datetime.now(timezone.utc) + platforms_table = sa.table( + 'platforms', + sa.column('name', sa.String), + sa.column('url', sa.String), + sa.column('created_at', sa.DateTime(timezone=True)), + ) + op.bulk_insert( + platforms_table, + [{'name': 'Referral', 'url': '', 'created_at': now}] + ) + + op.add_column('applications', sa.Column( + 'link_to_job', sa.String(length=2083), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('applications', 'link_to_job') + + platforms_table = sa.table( + 'platforms', + sa.column('name', sa.String), + sa.column('url', sa.String), + sa.column('created_at', sa.DateTime(timezone=True)), + ) + op.execute(sa.text(""" + UPDATE applications + SET platform_id = ( + SELECT id FROM platforms WHERE lower(name) = 'linkedin' LIMIT 1 + ) + WHERE platform_id = ( + SELECT id FROM platforms WHERE lower(name) = 'referral' LIMIT 1 + ) + """)) + op.execute( + platforms_table.delete().where(platforms_table.c.name == 'Referral') + ) + + # ### end Alembic commands ### diff --git a/frontend/src/features/applications/components/ApplicationCard.tsx b/frontend/src/features/applications/components/ApplicationCard.tsx index ca08953..1d316f1 100644 --- a/frontend/src/features/applications/components/ApplicationCard.tsx +++ b/frontend/src/features/applications/components/ApplicationCard.tsx @@ -10,6 +10,7 @@ import { ChevronDown, ChevronUp, } from "lucide-react"; +import Link from "next/link"; import CardDetails from "../CardDetails"; import { Application, Step } from "../types"; import { useApplicationSteps } from "@/features/applications/hooks/useApplicationSteps"; @@ -67,22 +68,6 @@ export default function ApplicationCard({ return ( { - const target = e.target as HTMLElement; - - // ignore clicks on interactive elements - if ( - target.closest("button") || - target.closest("[data-no-toggle]") || - target.closest("svg") || - target.closest("a") || - (target as HTMLElement).isContentEditable - ) { - return; - } - - toggleDetails(); - }} layout transition={{ layout: { duration: 0.35, ease: "easeInOut" } }} className="w-full h-full flex flex-col rounded-2xl p-4 bg-white/5 border border-white/20 backdrop-blur-xl shadow-lg hover:shadow-2xl hover:-translate-y-1 transition-all" @@ -93,9 +78,19 @@ export default function ApplicationCard({ {app.company}
- - {app.role} - + {app.link_to_job ? ( + + {app.role} + + ) : ( + + {app.role} + + )} - diff --git a/frontend/src/features/applications/modals/AddApplicationModal.tsx b/frontend/src/features/applications/modals/AddApplicationModal.tsx index 4ad7fc2..ce391d4 100644 --- a/frontend/src/features/applications/modals/AddApplicationModal.tsx +++ b/frontend/src/features/applications/modals/AddApplicationModal.tsx @@ -55,6 +55,7 @@ export default function AddApplicationModal({ expected_salary: undefined, salary_range_min: undefined, salary_range_max: undefined, + link_to_job: "", observation: "", }), [] @@ -72,6 +73,7 @@ export default function AddApplicationModal({ expected_salary: undefined, salary_range_min: undefined, salary_range_max: undefined, + link_to_job: "", observation: "", }); }, [isOpen, reset]); @@ -112,7 +114,7 @@ export default function AddApplicationModal({
+ +