diff --git a/src/app/db/migrations/versions/2024-07-31_add_people_model_698ddcfa9900.py b/src/app/db/migrations/versions/2024-07-31_add_people_model_698ddcfa9900.py new file mode 100644 index 00000000..9ac7839d --- /dev/null +++ b/src/app/db/migrations/versions/2024-07-31_add_people_model_698ddcfa9900.py @@ -0,0 +1,105 @@ +# type: ignore +"""Add people model + +Revision ID: 698ddcfa9900 +Revises: a05c476c0ae9 +Create Date: 2024-07-31 09:48:08.467309+00:00 + +""" +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +import sqlalchemy as sa +from alembic import op +from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC +from sqlalchemy import Text # noqa: F401 +from sqlalchemy.dialects import postgresql +from app.db.models.custom_types import LocationType, WorkExperienceType, SocialActivityType +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"] + +sa.GUID = GUID +sa.DateTimeUTC = DateTimeUTC +sa.ORA_JSONB = ORA_JSONB +sa.EncryptedString = EncryptedString +sa.EncryptedText = EncryptedText + +# revision identifiers, used by Alembic. +revision = '698ddcfa9900' +down_revision = 'a05c476c0ae9' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=UserWarning) + with op.get_context().autocommit_block(): + schema_upgrades() + data_upgrades() + +def downgrade() -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=UserWarning) + with op.get_context().autocommit_block(): + data_downgrades() + schema_downgrades() + +def schema_upgrades() -> None: + """schema upgrade migrations go here.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('person', + sa.Column('id', sa.GUID(length=16), nullable=False), + sa.Column('first_name', sa.String(), nullable=True), + sa.Column('last_name', sa.String(), nullable=True), + sa.Column('full_name', sa.String(), nullable=True), + sa.Column('headline', sa.String(length=500), nullable=True), + sa.Column('summary', sa.String(length=2000), nullable=True), + sa.Column('occupation', sa.String(), nullable=True), + sa.Column('industry', sa.String(), nullable=True), + sa.Column('profile_pic_url', sa.String(length=2083), nullable=True), + sa.Column('url', sa.String(length=2083), nullable=True), + sa.Column('linkedin_profile_url', sa.String(length=2083), nullable=True), + sa.Column('twitter_profile_url', sa.String(length=2083), nullable=True), + sa.Column('github_profile_url', sa.String(length=2083), nullable=True), + sa.Column('location', LocationType(), nullable=True), + sa.Column('personal_emails', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('work_emails', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('personal_numbers', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('birth_date', sa.Date(), nullable=True), + sa.Column('gender', sa.String(), nullable=True), + sa.Column('languages', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('work_experiences', WorkExperienceType(), nullable=True), + sa.Column('social_activities', SocialActivityType(), nullable=True), + sa.Column('slug', sa.String(length=100), nullable=False), + sa.Column('sa_orm_sentinel', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTimeUTC(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTimeUTC(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_person')), + sa.UniqueConstraint('slug', name='uq_person_slug') + ) + with op.batch_alter_table('person', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_person_industry'), ['industry'], unique=False) + batch_op.create_index('ix_person_slug_unique', ['slug'], unique=True) + + # ### end Alembic commands ### + +def schema_downgrades() -> None: + """schema downgrade migrations go here.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('person', schema=None) as batch_op: + batch_op.drop_index('ix_person_slug_unique') + batch_op.drop_index(batch_op.f('ix_person_industry')) + + op.drop_table('person') + # ### end Alembic commands ### + +def data_upgrades() -> None: + """Add any optional data upgrade migrations here!""" + +def data_downgrades() -> None: + """Add any optional data downgrade migrations here!""" diff --git a/src/app/db/models/__init__.py b/src/app/db/models/__init__.py index 2952dc3c..52797cc5 100644 --- a/src/app/db/models/__init__.py +++ b/src/app/db/models/__init__.py @@ -11,6 +11,7 @@ from .tenant import Tenant from .company import Company from .job_post import JobPost +from .person import Person __all__ = ( "User", @@ -25,5 +26,6 @@ "TeamRoles", "Tenant", "Company", - "JobPost" + "JobPost", + "Person" ) diff --git a/src/app/db/models/custom_types.py b/src/app/db/models/custom_types.py index 9218d944..faa29614 100644 --- a/src/app/db/models/custom_types.py +++ b/src/app/db/models/custom_types.py @@ -6,7 +6,7 @@ from sqlalchemy.types import TypeDecorator, TEXT from sqlalchemy.dialects.postgresql import JSONB -from app.lib.schema import Location, Funding +from app.lib.schema import Location, Funding, WorkExperience, SocialActivity class JSONBType(TypeDecorator): @@ -41,3 +41,18 @@ def process_result_value(self, value, dialect): if value and isinstance(value, dict): return Funding.from_dict(value) return None + + +class WorkExperienceType(JSONBType): + def process_result_value(self, value, dialect): + """Convert JSON format to Python object when reading from the database.""" + if value and isinstance(value, dict): + return WorkExperience.from_dict(value) + return None + +class SocialActivityType(JSONBType): + def process_result_value(self, value, dialect): + """Convert JSON format to Python object when reading from the database.""" + if value and isinstance(value, dict): + return SocialActivity.from_dict(value) + return None diff --git a/src/app/db/models/person.py b/src/app/db/models/person.py new file mode 100644 index 00000000..9511442e --- /dev/null +++ b/src/app/db/models/person.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from datetime import date + +from advanced_alchemy.base import SlugKey, UUIDAuditBase +from sqlalchemy import String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.lib.schema import Location, WorkExperience, SocialActivity +from .custom_types import LocationType, WorkExperienceType, SocialActivityType + + +class Person(UUIDAuditBase, SlugKey): + """A person.""" + + __tablename__ = "person" + __pii_columns__ = {"first_name", "last_name", "full_name", "linkedin_url", "profile_pic_url", "personal_emails", "work_emails", "personal_numbers", "social_activities"} + first_name: Mapped[str] = mapped_column(nullable=True, default=None) + last_name: Mapped[str] = mapped_column(nullable=True, default=None) + full_name: Mapped[str] = mapped_column(nullable=True, default=None) + headline: Mapped[str | None] = mapped_column(String(length=500), nullable=True, default=None) + summary: Mapped[str | None] = mapped_column(String(length=2000), nullable=True, default=None) + occupation: Mapped[str] = mapped_column(nullable=True, default=None) + industry: Mapped[str | None] = mapped_column(nullable=True, default=None, index=True) + profile_pic_url: Mapped[str | None] = mapped_column(String(length=2083), nullable=True, default=None) + url: Mapped[str | None] = mapped_column(String(length=2083), nullable=True, default=None) + linkedin_profile_url: Mapped[str | None] = mapped_column(String(length=2083), nullable=True, default=None) + twitter_profile_url: Mapped[str | None] = mapped_column(String(length=2083), nullable=True, default=None) + github_profile_url: Mapped[str | None] = mapped_column(String(length=2083), nullable=True, default=None) + location: Mapped[Location | None] = mapped_column(LocationType, nullable=True, default=None) + personal_emails: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True, default=None) + work_emails: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True, default=None) + personal_numbers: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True, default=None) + birth_date: Mapped[date | None] = mapped_column(nullable=True, default=None) + gender: Mapped[str | None] = mapped_column(nullable=True, default=None) + languages: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True, default=None) + work_experiences: Mapped[list[WorkExperience] | None] = mapped_column(WorkExperienceType, nullable=True, default=None) + social_activities: Mapped[list[SocialActivity] | None] = mapped_column(SocialActivityType, nullable=True, default=None) + # ----------- + # ORM Relationships + # ------------ diff --git a/src/app/domain/people/__init__.py b/src/app/domain/people/__init__.py new file mode 100644 index 00000000..18c61ee6 --- /dev/null +++ b/src/app/domain/people/__init__.py @@ -0,0 +1,4 @@ +"""People Application Module.""" +from . import controllers, dependencies, schemas, services + +__all__ = ["controllers", "services", "schemas", "dependencies"] diff --git a/src/app/domain/people/controllers/__init__.py b/src/app/domain/people/controllers/__init__.py new file mode 100644 index 00000000..132a7186 --- /dev/null +++ b/src/app/domain/people/controllers/__init__.py @@ -0,0 +1,3 @@ +from .persons import PersonController + +__all__ = ["PersonController"] diff --git a/src/app/domain/people/controllers/persons.py b/src/app/domain/people/controllers/persons.py new file mode 100644 index 00000000..b8ba432c --- /dev/null +++ b/src/app/domain/people/controllers/persons.py @@ -0,0 +1,129 @@ +"""Person Controllers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated + +from litestar import Controller, delete, get, patch, post +from litestar.di import Provide + +from app.config import constants +from app.domain.accounts.guards import requires_active_user +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.services import PersonService + +if TYPE_CHECKING: + from uuid import UUID + + from advanced_alchemy.service.pagination import OffsetPagination + from litestar.params import Dependency, Parameter + + from app.lib.dependencies import FilterTypes + + +class PersonController(Controller): + """Person operations.""" + + tags = ["Persons"] + dependencies = {"persons_service": Provide(provide_persons_service)} + guards = [requires_active_user] + signature_namespace = { + "PersonService": PersonService, + } + dto = None + return_dto = None + + @get( + operation_id="ListPersons", + name="persons:list", + summary="List Persons", + path=urls.PERSON_LIST, + ) + async def list_persons( + self, + persons_service: PersonService, + filters: Annotated[list[FilterTypes], Dependency(skip_validation=True)], + ) -> OffsetPagination[Person]: + """List persons that your account can access..""" + results, total = await persons_service.list_and_count(*filters) + return persons_service.to_schema(data=results, total=total, schema_type=Person, filters=filters) + + @post( + operation_id="CreatePerson", + name="persons:create", + summary="Create a new person.", + path=urls.PERSON_CREATE, + ) + async def create_person( + self, + persons_service: PersonService, + data: PersonCreate, + ) -> PersonCreate: + """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) + + @get( + operation_id="GetPerson", + name="persons:get", + summary="Retrieve the details of a person.", + path=urls.PERSON_DETAIL, + ) + async def get_person( + self, + persons_service: PersonService, + person_id: Annotated[ + UUID, + Parameter( + title="Person ID", + description="The person to retrieve.", + ), + ], + ) -> Person: + """Get details about a comapny.""" + db_obj = await persons_service.get(person_id) + return persons_service.to_schema(schema_type=Person, data=db_obj) + + @patch( + operation_id="UpdatePerson", + name="persons:update", + path=urls.PERSON_UPDATE, + ) + async def update_person( + self, + data: PersonUpdate, + persons_service: PersonService, + person_id: Annotated[ + UUID, + Parameter( + title="Person ID", + description="The person to update.", + ), + ], + ) -> Person: + """Update a person.""" + db_obj = await persons_service.update( + item_id=person_id, + data=data.to_dict(), + ) + return persons_service.to_schema(schema_type=Person, data=db_obj) + + @delete( + operation_id="DeletePerson", + name="persons:delete", + summary="Remove Person", + path=urls.PERSON_DELETE, + ) + async def delete_person( + self, + persons_service: PersonService, + person_id: Annotated[ + UUID, + Parameter(title="Person ID", description="The person to delete."), + ], + ) -> None: + """Delete a person.""" + _ = await persons_service.delete(person_id) diff --git a/src/app/domain/people/dependencies.py b/src/app/domain/people/dependencies.py new file mode 100644 index 00000000..453f572b --- /dev/null +++ b/src/app/domain/people/dependencies.py @@ -0,0 +1,27 @@ +"""People Controllers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy.orm import joinedload, noload, selectinload + +from app.db.models import Person +from app.domain.people.services import PersonService + +__all__ = ("provide_persons_service", ) + + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from sqlalchemy.ext.asyncio import AsyncSession + + +async def provide_persons_service(db_session: AsyncSession) -> AsyncGenerator[PersonService, None]: + """Construct repository and service objects for the request.""" + async with PersonService.new( + session=db_session, + load=[], + ) as service: + yield service diff --git a/src/app/domain/people/repositories.py b/src/app/domain/people/repositories.py new file mode 100644 index 00000000..3dbb0719 --- /dev/null +++ b/src/app/domain/people/repositories.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID # noqa: TCH003 + +from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemyAsyncSlugRepository +from sqlalchemy import ColumnElement, select +from sqlalchemy.orm import joinedload, selectinload + +from app.db.models import Person + +if TYPE_CHECKING: + from advanced_alchemy.filters import FilterTypes + +__all__ = ( + "PersonRepository", +) + + +class PersonRepository(SQLAlchemyAsyncSlugRepository[Person]): + """Person Repository.""" + + model_type = Person diff --git a/src/app/domain/people/schemas.py b/src/app/domain/people/schemas.py new file mode 100644 index 00000000..379cccdf --- /dev/null +++ b/src/app/domain/people/schemas.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from uuid import UUID # noqa: TCH003 +from datetime import date + +import msgspec + +from app.db.models.person import Person +from app.lib.schema import CamelizedBaseStruct, Location, WorkExperience, SocialActivity + + +class Person(CamelizedBaseStruct): + """A person.""" + id: UUID + slug: str + first_name: str | None = None + last_name: str | None = None + full_name: str | None = None + headline: str | None = None + summary: str | None = None + occupation: str | None = None + industry: str | None = None + profile_pic_url: str | None = None + url: str | None = None + linkedin_profile_url: str | None = None + twitter_profile_url: str | None = None + github_profile_url: str | None = None + location: Location | None = None + personal_emails: list[str] | None = None + work_emails: list[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 + + +class PersonCreate(CamelizedBaseStruct): + """A person create schema.""" + first_name: str | None = None + last_name: str | None = None + full_name: str | None = None + headline: str | None = None + summary: str | None = None + occupation: str | None = None + industry: str | None = None + profile_pic_url: str | None = None + url: str | None = None + linkedin_profile_url: str | None = None + twitter_profile_url: str | None = None + github_profile_url: str | None = None + location: Location | None = None + personal_emails: list[str] | None = None + work_emails: list[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 + + +class PersonUpdate(CamelizedBaseStruct, omit_defaults=True): + """A person update schema.""" + id: UUID + first_name: str | None | msgspec.UnsetType = msgspec.UNSET + last_name: str | None | msgspec.UnsetType = msgspec.UNSET + full_name: str | None | msgspec.UnsetType = msgspec.UNSET + headline: 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 + profile_pic_url: str | None | msgspec.UnsetType = msgspec.UNSET + url: str | None | msgspec.UnsetType = msgspec.UNSET + linkedin_profile_url: str | None | msgspec.UnsetType = msgspec.UNSET + twitter_profile_url: str | None | msgspec.UnsetType = msgspec.UNSET + github_profile_url: str | None | msgspec.UnsetType = msgspec.UNSET + location: Location | None | msgspec.UnsetType = msgspec.UNSET + personal_emails: list[str] | None | msgspec.UnsetType = msgspec.UNSET + work_emails: list[str] | None | msgspec.UnsetType = msgspec.UNSET + personal_numbers: list[str] | None | msgspec.UnsetType = msgspec.UNSET + birth_date: date | None | msgspec.UnsetType = msgspec.UNSET + gender: str | None | msgspec.UnsetType = msgspec.UNSET + languages: list[str] | None | msgspec.UnsetType = msgspec.UNSET + work_experiences: list[WorkExperience] | None | msgspec.UnsetType = msgspec.UNSET + social_activities: list[SocialActivity] | None | msgspec.UnsetType = msgspec.UNSET diff --git a/src/app/domain/people/services.py b/src/app/domain/people/services.py new file mode 100644 index 00000000..d6bd9cf3 --- /dev/null +++ b/src/app/domain/people/services.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from advanced_alchemy.exceptions import RepositoryError +from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, is_dict, is_msgspec_model, is_pydantic_model +from uuid_utils.compat import uuid4 + +from app.lib.schema import CamelizedBaseStruct, Location, WorkExperience, SocialActivity +from app.db.models import Person + +from .repositories import PersonRepository + +if TYPE_CHECKING: + from collections.abc import Iterable + from uuid import UUID + + from advanced_alchemy.filters import FilterTypes + from advanced_alchemy.repository._util import LoadSpec + from advanced_alchemy.service import ModelDictT + from msgspec import Struct + from sqlalchemy.orm import InstrumentedAttribute + +__all__ = ( + "PersonService", +) + + +class PersonService(SQLAlchemyAsyncRepositoryService[Person]): + """Person Service.""" + + repository_type = PersonRepository + match_fields = ["full_name"] + + def __init__(self, **repo_kwargs: Any) -> None: + self.repository: PersonRepository = self.repository_type(**repo_kwargs) + self.model_type = self.repository.model_type + + async def to_model(self, data: Person | dict[str, Any] | Struct, operation: str | None = None) -> Person: + if (is_msgspec_model(data) or is_pydantic_model(data)) and operation == "create" 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 == "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_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": + 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 new file mode 100644 index 00000000..2f2ac8a5 --- /dev/null +++ b/src/app/domain/people/urls.py @@ -0,0 +1,6 @@ +PERSON_LIST = "/api/persons" +PERSON_DELETE = "/api/persons/{company_id:uuid}" +PERSON_DETAIL = "/api/persons/{company_id:uuid}" +PERSON_UPDATE = "/api/persons/{company_id:uuid}" +PERSON_CREATE = "/api/persons" +PERSON_INDEX = "/api/persons/{company_id:uuid}" diff --git a/src/app/lib/schema.py b/src/app/lib/schema.py index ff3a5331..55bd6dd6 100644 --- a/src/app/lib/schema.py +++ b/src/app/lib/schema.py @@ -44,3 +44,22 @@ class Funding(CamelizedBaseStruct): money_raised: int | None = None announced_date: date | None = None investors: list[Investor] = [] + + +class WorkExperience(CamelizedBaseStruct): + """Work experience data.""" + starts_at: date + title: str + company_name: str + ends_at: date | None = None + linkedin_profile_url: str | None = None + description: str | None = None + location: Location | None = None + logo_url: str | None = None + + +class SocialActivity(CamelizedBaseStruct): + """Social activity data.""" + title: str + link: str | None = None + status: str | None = None diff --git a/src/app/server/routers.py b/src/app/server/routers.py index 4a10dfb4..31470287 100644 --- a/src/app/server/routers.py +++ b/src/app/server/routers.py @@ -9,6 +9,7 @@ from app.domain.teams.controllers import TeamController, TeamMemberController from app.domain.companies.controllers import CompanyController from app.domain.jobs.controllers import JobPostController +from app.domain.people.controllers import PersonController if TYPE_CHECKING: from litestar.types import ControllerRouterHandler @@ -25,5 +26,6 @@ TenantController, CompanyController, JobPostController, + PersonController, SystemController, ]