Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added generalized flow for applications #453

Merged
merged 4 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions apps/api/src/models/ApplicationData.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,41 @@ class BaseApplicationData(BaseModel):
is_first_hackathon: bool
linkedin: NullableHttpUrl = None
portfolio: NullableHttpUrl = None
frq_collaboration: Union[str, None] = Field(None, max_length=2048)
frq_dream_job: str = Field(max_length=2048)
frq_change: Union[str, None] = Field(None, max_length=2048)
frq_video_game: str = Field(max_length=2048)


class RawApplicationData(BaseApplicationData):
class BaseMentorApplicationData(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, str_max_length=254)

experienced_technologies: str
pronouns: str

school: str
major: str
education_level: str
is_18_older: str
git_experience: str
github: NullableHttpUrl = None
portfolio: NullableHttpUrl = None
linkedin: NullableHttpUrl = None
mentor_prev_experience_saq1: Union[str, None] = Field(None, max_length=2048)
mentor_interest_saq2: Union[str, None] = Field(None, max_length=2048)
mentor_team_help_saq3: Union[str, None] = Field(None, max_length=2048)
mentor_team_help_saq4: Union[str, None] = Field(None, max_length=2048)
other_questions: Union[str, None] = Field(None, max_length=2048)


class RawHackerApplicationData(BaseApplicationData):
"""Expected to be sent by the form on the site."""

first_name: str
last_name: str
resume: Union[UploadFile, None] = None
application_type: str


class RawMentorApplicationData(BaseMentorApplicationData):
"""Expected to be sent by the form on the site."""

first_name: str
Expand All @@ -58,7 +88,19 @@ class RawApplicationData(BaseApplicationData):
application_type: str


class ProcessedApplicationData(BaseApplicationData):
class ProcessedHackerApplicationData(BaseApplicationData):
resume_url: Union[HttpUrl, None] = None
submission_time: datetime
reviews: list[Review] = []

@field_serializer("linkedin", "portfolio", "resume_url")
def url2str(self, val: Union[HttpUrl, None]) -> Union[str, None]:
if val is not None:
return str(val)
return val


class ProcessedMentorApplicationData(BaseMentorApplicationData):
resume_url: Union[HttpUrl, None] = None
submission_time: datetime
reviews: list[Review] = []
Expand Down
10 changes: 8 additions & 2 deletions apps/api/src/models/user_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
from pydantic import AfterValidator, Field
from typing_extensions import TypeAlias

from models.ApplicationData import Decision, ProcessedApplicationData
from models.ApplicationData import (
Decision,
ProcessedHackerApplicationData,
ProcessedMentorApplicationData,
)
from services.mongodb_handler import BaseRecord


Expand Down Expand Up @@ -72,4 +76,6 @@ class Applicant(BareApplicant):

# Note validators not run on default values
roles: RoleWithApplicant = (Role.APPLICANT,)
application_data: ProcessedApplicationData
application_data: Union[
ProcessedHackerApplicationData, ProcessedMentorApplicationData
]
61 changes: 47 additions & 14 deletions apps/api/src/routers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@

from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, TypeAdapter

from auth import user_identity
from auth.authorization import require_accepted_applicant
from auth.user_identity import User, require_user_identity, use_user_identity
from models.ApplicationData import ProcessedApplicationData, RawApplicationData
from models.ApplicationData import (
ProcessedHackerApplicationData,
RawHackerApplicationData,
RawMentorApplicationData,
ProcessedMentorApplicationData,
)
from models.user_record import Applicant, BareApplicant, Role, Status
from services import docusign_handler, mongodb_handler
from services.docusign_handler import WebhookPayload
Expand Down Expand Up @@ -67,14 +72,11 @@ async def me(
return IdentityResponse(uid=user.uid, **user_record)


@router.post("/apply", status_code=status.HTTP_201_CREATED)
async def apply(
user: Annotated[User, Depends(require_user_identity)],
# media type should be automatically detected but seems like a bug as of now
raw_application_data: Annotated[
RawApplicationData, Form(media_type="multipart/form-data")
],
async def apply_flow(
IanWearsHat marked this conversation as resolved.
Show resolved Hide resolved
user: User,
raw_application_data: Union[RawHackerApplicationData, RawMentorApplicationData],
) -> str:
print("apply_flow called")
IanWearsHat marked this conversation as resolved.
Show resolved Hide resolved
if raw_application_data.application_type not in Role.__members__:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
Expand Down Expand Up @@ -103,7 +105,7 @@ async def apply(
raw_app_data_dump = raw_application_data.model_dump()

for field in ["pronouns", "ethnicity", "school", "major"]:
if raw_app_data_dump[field] == "other":
if field in raw_app_data_dump and raw_app_data_dump[field] == "other":
IanWearsHat marked this conversation as resolved.
Show resolved Hide resolved
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"Please enable JavaScript on your browser.",
Expand Down Expand Up @@ -131,10 +133,17 @@ async def apply(
else:
resume_url = None

processed_application_data = ProcessedApplicationData(
**raw_app_data_dump,
resume_url=resume_url,
submission_time=now,
ProcessedApplicationData: TypeAdapter[
Union[ProcessedHackerApplicationData, ProcessedMentorApplicationData]
] = TypeAdapter(
Union[ProcessedHackerApplicationData, ProcessedMentorApplicationData]
)
processed_application_data = ProcessedApplicationData.validate_python(
{
**raw_app_data_dump,
"resume_url": resume_url,
"submission_time": now,
}
)
applicant = Applicant(
uid=user.uid,
Expand Down Expand Up @@ -174,6 +183,30 @@ async def apply(
)


@router.post("/apply", status_code=status.HTTP_201_CREATED)
async def apply(
user: Annotated[User, Depends(require_user_identity)],
# media type should be automatically detected but seems like a bug as of now
raw_application_data: Annotated[
RawHackerApplicationData,
Form(media_type="multipart/form-data"),
],
) -> str:
return await apply_flow(user, raw_application_data)


@router.post("/mentor", status_code=status.HTTP_201_CREATED)
async def mentor(
user: Annotated[User, Depends(require_user_identity)],
# media type should be automatically detected but seems like a bug as of now
raw_application_data: Annotated[
RawMentorApplicationData,
Form(media_type="multipart/form-data"),
],
) -> str:
return await apply_flow(user, raw_application_data)


@router.get("/waiver")
async def request_waiver(
user: Annotated[tuple[User, BareApplicant], Depends(require_accepted_applicant)]
Expand Down
12 changes: 6 additions & 6 deletions apps/api/tests/test_user_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pydantic import HttpUrl

from auth.user_identity import NativeUser, UserTestClient
from models.ApplicationData import ProcessedApplicationData
from models.ApplicationData import ProcessedHackerApplicationData
from models.user_record import Applicant, Status, Role
from routers import user
from services.mongodb_handler import Collection
Expand Down Expand Up @@ -37,8 +37,8 @@
"is_first_hackathon": "false",
"linkedin": "",
"portfolio": "https://github.com",
"frq_collaboration": "I am pkfire",
"frq_dream_job": "I am pkfire",
"frq_change": "I am pkfire",
"frq_video_game": "I am pkfire",
"application_type": "HACKER",
}

Expand All @@ -60,15 +60,15 @@
SAMPLE_SUBMISSION_TIME = datetime(2024, 1, 12, 8, 1, 21, tzinfo=timezone.utc)
SAMPLE_VERDICT_TIME = None

EXPECTED_APPLICATION_DATA = ProcessedApplicationData(
EXPECTED_APPLICATION_DATA = ProcessedHackerApplicationData(
**SAMPLE_APPLICATION, # type: ignore[arg-type]
resume_url=SAMPLE_RESUME_URL,
submission_time=SAMPLE_SUBMISSION_TIME,
verdict_time=SAMPLE_VERDICT_TIME,
)
assert EXPECTED_APPLICATION_DATA.linkedin is None

EXPECTED_APPLICATION_DATA_WITHOUT_RESUME = ProcessedApplicationData(
EXPECTED_APPLICATION_DATA_WITHOUT_RESUME = ProcessedHackerApplicationData(
**SAMPLE_APPLICATION, # type: ignore[arg-type]
resume_url=None,
submission_time=SAMPLE_SUBMISSION_TIME,
Expand Down Expand Up @@ -302,7 +302,7 @@ def test_application_data_is_bson_encodable() -> None:
data = EXPECTED_APPLICATION_DATA.model_copy()
data.linkedin = HttpUrl("https://linkedin.com")
encoded = bson.encode(EXPECTED_APPLICATION_DATA.model_dump())
assert len(encoded) == 376
assert len(encoded) == 370


@patch("services.mongodb_handler.retrieve_one", autospec=True)
Expand Down
141 changes: 141 additions & 0 deletions apps/api/tests/test_user_mentor_apply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock, Mock, patch

from fastapi import FastAPI
from pydantic import HttpUrl

from auth.user_identity import NativeUser, UserTestClient
from models.ApplicationData import (
ProcessedMentorApplicationData,
)
from models.user_record import Applicant, Status, Role
from routers import user
from services.mongodb_handler import Collection
from utils import resume_handler

# Tests will break again next year, tech should notice and fix :P
TEST_DEADLINE = datetime(2025, 10, 1, 8, 0, 0, tzinfo=timezone.utc)
user.DEADLINE = TEST_DEADLINE

USER_EMAIL = "[email protected]"
USER_PKFIRE = NativeUser(
ucinetid="pkfire",
display_name="pkfire",
email=USER_EMAIL,
affiliations=["pkfire"],
)

SAMPLE_APPLICATION = {
"first_name": "pk",
"last_name": "fire",
"experienced_technologies": "",
"pronouns": "",
"is_18_older": "true",
"school": "UC Irvine",
"education_level": "Fifth+ Year Undergraduate",
"major": "Computer Science",
"git_experience": "2",
"github": "https://github.com",
"portfolio": "",
"linkedin": "",
"mentor_prev_experience_saq1": "",
"mentor_interest_saq2": "",
"mentor_team_help_saq3": "",
"mentor_team_help_saq4": "",
"other_questions": "",
"application_type": "MENTOR",
}


SAMPLE_RESUME = ("my-resume.pdf", b"resume", "application/pdf")
SAMPLE_FILES = {"resume": SAMPLE_RESUME}
BAD_RESUME = ("bad-resume.doc", b"resume", "application/msword")
LARGE_RESUME = ("large-resume.pdf", b"resume" * 100_000, "application/pdf")
# The browser will send an empty file if not selected
EMPTY_RESUME = (
"",
b"",
"application/octet-stream",
{"content-disposition": 'form-data; name="resume"; filename=""'},
)

EXPECTED_RESUME_UPLOAD = ("pk-fire-69f2afc2.pdf", b"resume", "application/pdf")
SAMPLE_RESUME_URL = HttpUrl("https://drive.google.com/file/d/...")
SAMPLE_SUBMISSION_TIME = datetime(2024, 1, 12, 8, 1, 21, tzinfo=timezone.utc)
SAMPLE_VERDICT_TIME = None

EXPECTED_APPLICATION_DATA = ProcessedMentorApplicationData(
**SAMPLE_APPLICATION, # type: ignore[arg-type]
resume_url=SAMPLE_RESUME_URL,
submission_time=SAMPLE_SUBMISSION_TIME,
verdict_time=SAMPLE_VERDICT_TIME,
)
assert EXPECTED_APPLICATION_DATA.linkedin is None

EXPECTED_APPLICATION_DATA_WITHOUT_RESUME = ProcessedMentorApplicationData(
**SAMPLE_APPLICATION, # type: ignore[arg-type]
resume_url=None,
submission_time=SAMPLE_SUBMISSION_TIME,
verdict_time=SAMPLE_VERDICT_TIME,
)

EXPECTED_USER = Applicant(
uid="edu.uci.pkfire",
first_name="pk",
last_name="fire",
roles=(Role.APPLICANT, Role.MENTOR),
application_data=EXPECTED_APPLICATION_DATA,
status=Status.PENDING_REVIEW,
)

EXPECTED_USER_WITHOUT_RESUME = Applicant(
uid="edu.uci.pkfire",
first_name="pk",
last_name="fire",
roles=(Role.APPLICANT, Role.HACKER),
status=Status.PENDING_REVIEW,
application_data=EXPECTED_APPLICATION_DATA_WITHOUT_RESUME,
)

resume_handler.RESUMES_FOLDER_ID = "RESUMES_FOLDER_ID"

app = FastAPI()
app.include_router(user.router)

client = UserTestClient(USER_PKFIRE, app)


@patch("utils.email_handler.send_application_confirmation_email", autospec=True)
@patch("services.mongodb_handler.update_one", autospec=True)
@patch("routers.user._is_past_deadline", autospec=True)
@patch("routers.user.datetime", autospec=True)
@patch("services.gdrive_handler.upload_file", autospec=True)
@patch("services.mongodb_handler.retrieve_one", autospec=True)
def test_mentor_apply_successfully(
mock_mongodb_handler_retrieve_one: AsyncMock,
mock_gdrive_handler_upload_file: AsyncMock,
mock_datetime: Mock,
mock_is_past_deadline: Mock,
mock_mongodb_handler_update_one: AsyncMock,
mock_send_application_confirmation_email: AsyncMock,
) -> None:
"""Test that a valid application is submitted properly."""
mock_mongodb_handler_retrieve_one.return_value = None
mock_gdrive_handler_upload_file.return_value = SAMPLE_RESUME_URL
mock_datetime.now.return_value = SAMPLE_SUBMISSION_TIME
mock_is_past_deadline.return_value = False
res = client.post("/mentor", data=SAMPLE_APPLICATION, files=SAMPLE_FILES)

mock_gdrive_handler_upload_file.assert_awaited_once_with(
resume_handler.RESUMES_FOLDER_ID, *EXPECTED_RESUME_UPLOAD
)
mock_mongodb_handler_update_one.assert_awaited_once_with(
Collection.USERS,
{"_id": EXPECTED_USER.uid},
EXPECTED_USER.model_dump(),
upsert=True,
)
mock_send_application_confirmation_email.assert_awaited_once_with(
USER_EMAIL, EXPECTED_USER, Role.MENTOR
)
assert res.status_code == 201
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default function BasicInformation() {
placeholder="Anteater"
/>
<DropdownSelect
name="pronoun"
name="pronouns"
labelText="Pronouns"
containerClass="flex flex-col w-3/12 max-[1000px]:w-full"
values={pronouns}
Expand Down
2 changes: 1 addition & 1 deletion apps/site/src/app/(main)/apply/Form/HackerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import ProfileInformation from "./ProfileInformation";

export default function HackerForm() {
return (
<Form>
<Form applicationType="HACKER">
<BasicInformation />
<SchoolInformation />
<ProfileInformation />
Expand Down
Loading
Loading