From 1b7217c9692df9d9ffba1ab791b79726f6c9b99e Mon Sep 17 00:00:00 2001 From: shri Date: Fri, 30 Aug 2024 17:56:18 +0200 Subject: [PATCH] Add create person from url endpoint --- src/app/domain/people/controllers/persons.py | 110 ++++++++++++++++++- src/app/domain/people/schemas.py | 12 +- src/app/domain/people/services.py | 10 +- src/app/domain/people/urls.py | 1 + 4 files changed, 125 insertions(+), 8 deletions(-) diff --git a/src/app/domain/people/controllers/persons.py b/src/app/domain/people/controllers/persons.py index b8ba432c..922ac5a0 100644 --- a/src/app/domain/people/controllers/persons.py +++ b/src/app/domain/people/controllers/persons.py @@ -2,16 +2,24 @@ from __future__ import annotations +import structlog +from datetime import date, datetime, timezone, timedelta from typing import TYPE_CHECKING, Annotated +from advanced_alchemy.filters import SearchFilter, LimitOffset from litestar import Controller, delete, get, patch, post from litestar.di import Provide from app.config import constants +from app.lib.schema import Location, WorkExperience, SocialActivity +from app.lib.pdl import get_person_details from app.domain.accounts.guards import requires_active_user +from app.domain.companies.dependencies import provide_companies_service +from app.domain.companies.schemas import CompanyCreate +from app.domain.companies.services import CompanyService from app.domain.people import urls from app.domain.people.dependencies import provide_persons_service -from app.domain.people.schemas import Person, PersonCreate, PersonUpdate +from app.domain.people.schemas import Person, PersonCreate, PersonCreateFromURL, PersonUpdate from app.domain.people.services import PersonService if TYPE_CHECKING: @@ -22,12 +30,17 @@ from app.lib.dependencies import FilterTypes +logger = structlog.get_logger() + class PersonController(Controller): """Person operations.""" tags = ["Persons"] - dependencies = {"persons_service": Provide(provide_persons_service)} + dependencies = { + "companies_service": Provide(provide_companies_service), + "persons_service": Provide(provide_persons_service), + } guards = [requires_active_user] signature_namespace = { "PersonService": PersonService, @@ -60,12 +73,103 @@ async def create_person( self, persons_service: PersonService, data: PersonCreate, - ) -> PersonCreate: + ) -> Person: """Create a new person.""" obj = data.to_dict() db_obj = await persons_service.create(obj) return persons_service.to_schema(schema_type=Person, data=db_obj) + @post( + operation_id="CreatePersonFromURL", + name="persons:create-from-url", + summary="Create a new person from URL.", + path=urls.PERSON_CREATE_FROM_URL, + ) + async def create_person_from_url( + self, + companies_service: CompanyService, + persons_service: PersonService, + data: PersonCreateFromURL, + ) -> Person: + """Create a new person from URL.""" + # Check if person already exists in the database + filters = [ + SearchFilter(field_name="linkedin_profile_url", value=data.url.rstrip("/"), ignore_case=True), + LimitOffset(limit=1, offset=0), + ] + results, count = await persons_service.list_and_count(*filters) + + now = datetime.now(timezone.utc) + four_weeks_ago = now - timedelta(weeks=4) + + if count > 0 and results[0].updated_at > four_weeks_ago: + await logger.ainfo("Person already exists and is up-to-date", person=results[0]) + return persons_service.to_schema(schema_type=Person, data=results[0]) + + # Extract person from data provider + person_details = await get_person_details(data.url) + + linkedin_profile_url = None + twitter_profile_url = None + github_profile_url = None + birth_date = None + + if person_details.get("linkedin_url"): + linkedin_profile_url = "https://" + person_details.get("linkedin_url").rstrip("/") + if person_details.get("twitter_url"): + twitter_profile_url = "https://" + person_details.get("twitter_url").rstrip("/") + if person_details.get("github_url"): + github_profile_url = "https://" + person_details.get("github_url").rstrip("/") + if person_details.get("birth_date"): + birth_date = datetime.strptime(person_details.get("birth_date"), "%Y-%m-%d").date() + + # Add or update company + company = CompanyCreate( + name=person_details.get("job_company_name"), + url=person_details.get("job_company_website"), + linkedin_profile_url=person_details.get("job_company_linkedin_url"), + ) + company_db_obj = await companies_service.create(company.to_dict()) + + # Add person + # TODO: Move this code into a provider specific code + obj = PersonCreate( + first_name=person_details.get("first_name"), + last_name=person_details.get("last_name"), + full_name=person_details.get("full_name"), + title=person_details.get("job_title"), + occupation=person_details.get("job_title_role"), + industry=person_details.get("industry"), + linkedin_profile_url=linkedin_profile_url, + twitter_profile_url=twitter_profile_url, + github_profile_url=github_profile_url, + location=Location( + country=person_details.get("location_country"), + region=person_details.get("location_region"), + city=person_details.get("location_locality"), + ), + personal_emails=person_details.get("personal_emails", []), + work_email=person_details.get("work_email"), + personal_numbers=person_details.get("personal_numbers", []), + birth_date=birth_date, + work_experiences=[ + WorkExperience( + starts_at=datetime.strptime(work_ex.get("start_date"), "%Y-%m").date(), + title=work_ex.get("title", {}).get("name"), + company_name=work_ex.get("company", {}).get("name"), + company_url=work_ex.get("company", {}).get("website"), + company_linkedin_profile_url=work_ex.get("linkedin_url"), + ends_at=datetime.strptime(work_ex.get("end_date"), "%Y-%m").date() + if work_ex.get("end_date") + else None, + ) + for work_ex in person_details.get("experience", []) + ], + company_id=company_db_obj.id, + ) + db_obj = await persons_service.upsert(obj.to_dict(), item_id=results[0].id if count > 0 else None) + return persons_service.to_schema(schema_type=Person, data=db_obj) + @get( operation_id="GetPerson", name="persons:get", diff --git a/src/app/domain/people/schemas.py b/src/app/domain/people/schemas.py index ad35d80b..e794abd7 100644 --- a/src/app/domain/people/schemas.py +++ b/src/app/domain/people/schemas.py @@ -20,6 +20,7 @@ class Person(CamelizedBaseStruct): last_name: str | None = None full_name: str | None = None headline: str | None = None + title: str | None = None summary: str | None = None occupation: str | None = None industry: str | None = None @@ -46,6 +47,7 @@ class PersonCreate(CamelizedBaseStruct): last_name: str | None = None full_name: str | None = None headline: str | None = None + title: str | None = None summary: str | None = None occupation: str | None = None industry: str | None = None @@ -56,13 +58,20 @@ class PersonCreate(CamelizedBaseStruct): github_profile_url: str | None = None location: Location | None = None personal_emails: list[str] | None = None - work_emails: list[str] | None = None + work_email: str | None = None personal_numbers: list[str] | None = None birth_date: date | None = None gender: str | None = None languages: list[str] | None = None work_experiences: list[WorkExperience] | None = None social_activities: list[SocialActivity] | None = None + company_id: str | None = None + + +class PersonCreateFromURL(CamelizedBaseStruct): + """A person create from URL schema.""" + + url: str class PersonUpdate(CamelizedBaseStruct, omit_defaults=True): @@ -73,6 +82,7 @@ class PersonUpdate(CamelizedBaseStruct, omit_defaults=True): last_name: str | None | msgspec.UnsetType = msgspec.UNSET full_name: str | None | msgspec.UnsetType = msgspec.UNSET headline: str | None | msgspec.UnsetType = msgspec.UNSET + title: str | None | msgspec.UnsetType = msgspec.UNSET summary: str | None | msgspec.UnsetType = msgspec.UNSET occupation: str | None | msgspec.UnsetType = msgspec.UNSET industry: str | None | msgspec.UnsetType = msgspec.UNSET diff --git a/src/app/domain/people/services.py b/src/app/domain/people/services.py index d6bd9cf3..d70bf137 100644 --- a/src/app/domain/people/services.py +++ b/src/app/domain/people/services.py @@ -21,9 +21,7 @@ from msgspec import Struct from sqlalchemy.orm import InstrumentedAttribute -__all__ = ( - "PersonService", -) +__all__ = ("PersonService",) class PersonService(SQLAlchemyAsyncRepositoryService[Person]): @@ -41,8 +39,12 @@ async def to_model(self, data: Person | dict[str, Any] | Struct, operation: str data.slug = await self.repository.get_available_slug(data.name) # type: ignore[union-attr] if (is_msgspec_model(data) or is_pydantic_model(data)) and operation == "update" and data.slug is None: # type: ignore[union-attr] data.slug = await self.repository.get_available_slug(data.name) # type: ignore[union-attr] + if (is_msgspec_model(data) or is_pydantic_model(data)) and operation == "upsert" and data.slug is None: # type: ignore[union-attr] + data.slug = await self.repository.get_available_slug(data.name) # type: ignore[union-attr] if is_dict(data) and "slug" not in data and operation == "create": data["slug"] = await self.repository.get_available_slug(data["full_name"]) - if is_dict(data) and "slug" not in data and "name" in data and operation == "update": + if is_dict(data) and "slug" not in data and "full_name" in data and operation == "update": + data["slug"] = await self.repository.get_available_slug(data["full_name"]) + if is_dict(data) and "slug" not in data and "full_name" in data and operation == "upsert": data["slug"] = await self.repository.get_available_slug(data["full_name"]) return await super().to_model(data, operation) diff --git a/src/app/domain/people/urls.py b/src/app/domain/people/urls.py index 2f2ac8a5..141d76dd 100644 --- a/src/app/domain/people/urls.py +++ b/src/app/domain/people/urls.py @@ -3,4 +3,5 @@ PERSON_DETAIL = "/api/persons/{company_id:uuid}" PERSON_UPDATE = "/api/persons/{company_id:uuid}" PERSON_CREATE = "/api/persons" +PERSON_CREATE_FROM_URL = "/api/persons/from-url" PERSON_INDEX = "/api/persons/{company_id:uuid}"