diff --git a/src/hrflow_connectors/v2/__init__.py b/src/hrflow_connectors/v2/__init__.py
index 5b02fcd28..d89fa8830 100644
--- a/src/hrflow_connectors/v2/__init__.py
+++ b/src/hrflow_connectors/v2/__init__.py
@@ -1,5 +1,6 @@
from hrflow_connectors.v2.connectors.admen import Admen
from hrflow_connectors.v2.connectors.adzuna import Adzuna
+from hrflow_connectors.v2.connectors.breezyhr import BreezyHR
from hrflow_connectors.v2.connectors.bullhorn import Bullhorn
from hrflow_connectors.v2.connectors.flatchr import Flatchr
from hrflow_connectors.v2.connectors.francetravail import FranceTravail
@@ -24,4 +25,5 @@
Adzuna,
FranceTravail,
Flatchr,
+ BreezyHR,
]
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/README.md b/src/hrflow_connectors/v2/connectors/breezyhr/README.md
new file mode 100644
index 000000000..42ac58c8a
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/README.md
@@ -0,0 +1,71 @@
+# π Summary
+- [π Summary](#-summary)
+- [π About BreezyHR](#-about-breezyhr)
+- [π Data Flow](#-data-flow)
+- [π Connector Actions](#-connector-actions)
+- [π Quick Start Examples](#-quick-start-examples)
+ - [**Push Profiles Action**](#push-profiles-action)
+ - [**Pull Jobs Action**](#pull-jobs-action)
+- [π Useful Links](#-useful-links)
+- [π Special Thanks](#-special-thanks)
+
+
+# π About BreezyHR
+
+BreezyHr is a software designed to assist teams in finding the right candidates, evaluating applicants, and making a hire more quickly.
+It helps streamline the recruitment process and identify the best fit for a position.
+
+
+
+
+
+
+# π Data Flow
+In this section, we outline the data flow between different components of the connector. The following schema provides a graphical representation of the data exchange process
+
+
+
+
+
+# π Connector Actions
+
+
+| Action | Description |
+| ------- | ----------- |
+| [**Pull job list**](docs/pull_job_list.md) | Retrieves all jobs via the ***BreezyHR*** API and send them to a ***Hrflow.ai Board***. |
+| [**Push profile list**](docs/push_profile_list.md) | Push all profiles from ***Hrflow.ai Source*** via ***BreezyHR*** API and send them to a ***BreezyHR***. |
+| [**Pull profile list**](docs/pull_profile_list.md) | Retrieves all profiles via the ***BreezyHR*** API and send them to a ***Hrflow.ai Source***. |
+
+
+
+
+
+# π Quick Start Examples
+
+
+To make sure you can successfully run the latest versions of the example scripts, you have to **install the package from PyPi**.
+To browse the examples of actions corresponding to released versions of π€ this connector, you just need to import the module like this :
+
+
+
+
+
+
+Once the connector module is imported, you can leverage all the different actions that it offers.
+
+For more code details checkout connector code
+
+# π Useful Links
+
+- πVisit [BreezyHR](https://breezy.hr/) to learn more.
+- βοΈ API documentation : (https://developer.breezy.hr/reference/overview)
+- π» [Connector code](https://github.com/Riminder/hrflow-connectors/tree/master/src/hrflow_connectors/connectors/breezyhr) on our Github.
+
+
+# π Special Thanks
+- π» HrFlow.ai : [Limam VADHEL](https://github.com/limamvadhel) - Software Engineer
+- π» HrFlow.ai : [Leo FERRETTI](https://github.com/Sprenger07) - Software Engineer
+- π» HrFlow.ai :[Corentin DUCHENE](https://github.com/CorentinDuchene) - Software Engineer
+- π» HrFlow.ai : [Nedhir Ebnou](https://github.com/nedhirouebnou) - Software Engineer
+- π€ BreezyHR :[Breezy HR for the partnership and accessible documentation](https://breezy.hr/)
+
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/__init__.py b/src/hrflow_connectors/v2/connectors/breezyhr/__init__.py
new file mode 100644
index 000000000..1528fac9d
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/__init__.py
@@ -0,0 +1 @@
+from hrflow_connectors.v2.connectors.breezyhr.connector import BreezyHR # noqa
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/aisles.py b/src/hrflow_connectors/v2/connectors/breezyhr/aisles.py
new file mode 100644
index 000000000..ef8e66a7c
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/aisles.py
@@ -0,0 +1,636 @@
+import json
+import typing as t
+from datetime import datetime, timedelta
+from enum import Enum
+from io import BytesIO
+from logging import LoggerAdapter
+
+import requests
+from msgspec import Meta, Struct
+from typing_extensions import Annotated
+
+from hrflow_connectors.v2.connectors.breezyhr.schemas import (
+ BreezyJobModel,
+ BreezyProfileModel,
+)
+from hrflow_connectors.v2.core.common import Entity, Mode
+from hrflow_connectors.v2.core.warehouse import (
+ Aisle,
+ Criterias,
+ ReadOperation,
+ WriteOperation,
+ merge,
+)
+
+BREEZY_BASE_URL = "https://api.breezy.hr/v3"
+
+
+class State(str, Enum):
+ draft = "draft"
+ archived = "archived"
+ published = "published"
+ closed = "closed"
+ pending = "pending"
+
+
+class Origin(str, Enum):
+ sourced = "sourced"
+ applied = "applied"
+
+
+class AuthParameters(Struct):
+ email: Annotated[
+ str,
+ Meta(
+ description="email",
+ ),
+ ]
+ password: Annotated[str, Meta(description="password")]
+ company_id: Annotated[
+ t.Optional[str],
+ Meta(
+ description=(
+ "ID of company to pull jobs from in Breezy HR database associated with"
+ " the authenticated user"
+ ),
+ ),
+ ] = None
+ company_name: Annotated[
+ t.Optional[str],
+ Meta(
+ description=(
+ "[β οΈ Requiered if company_id is not specified], the company associated"
+ " with the authenticated user"
+ ),
+ ),
+ ] = None
+
+
+class ReadJobsParameters(Struct):
+ state: Annotated[
+ State,
+ Meta(
+ description=(
+ "Specify an optional position state filter. e.g. draft, archived,"
+ " published, closed, pending\nDefaults to published"
+ ),
+ ),
+ ] = State.published
+
+
+class WriteProfilesParameters(Struct):
+ position_id: Annotated[
+ str,
+ Meta(
+ description="Id of the position to create a new candidate for",
+ ),
+ ]
+ origin: Annotated[
+ Origin,
+ Meta(
+ description=(
+ "will indicate in Breezy if the candidate should be marked as sourced"
+ " or applied"
+ ),
+ ),
+ ] = Origin.sourced
+ stage_actions_enabled: Annotated[
+ bool,
+ Meta(
+ description=(
+ 'When origin is "sourced", should stage actions be executed (defaults'
+ ' to false). This is always true when origin is "applied"'
+ ),
+ ),
+ ] = False
+
+
+class UpdateProfilesParameters(Struct):
+ position_id: Annotated[
+ str,
+ Meta(
+ description="Id of the position to create a new candidate for",
+ ),
+ ]
+
+
+class ReadProfilesParameters(Struct):
+ position_id: Annotated[
+ t.Optional[str],
+ Meta(
+ description="Id of the position to create a new candidate for",
+ ),
+ ] = None
+
+
+def get_access_token(
+ adapter: LoggerAdapter,
+ auth_parameters: AuthParameters,
+):
+ sign_in_url = f"{BREEZY_BASE_URL}/signin"
+
+ response = requests.post(
+ sign_in_url,
+ json={"email": auth_parameters.email, "password": auth_parameters.password},
+ )
+
+ if not response.ok:
+ adapter.error(f"failed to retrieve access token, reason: {response.text}")
+ raise Exception("failed to retrieve access token")
+
+ return response.json().get("access_token")
+
+
+def revoke_access_token(access_token):
+ sign_out_url = f"{BREEZY_BASE_URL}/signout"
+
+ headers = {"Authorization": f"{access_token}"}
+
+ requests.get(sign_out_url, headers=headers)
+
+
+def generic_jobs_read(
+ mode: Mode,
+):
+ def read_jobs(
+ adapter: LoggerAdapter,
+ auth_parameters: AuthParameters,
+ parameters: ReadJobsParameters,
+ incremental: bool,
+ incremental_token: t.Optional[str],
+ ) -> t.Iterable[t.Dict]:
+ access_token = get_access_token(adapter, auth_parameters)
+ headers = {"Authorization": access_token}
+
+ company_id = auth_parameters.company_id
+ if company_id is None:
+ companies_url = f"{BREEZY_BASE_URL}/companies"
+
+ companies_response = requests.get(companies_url, headers=headers)
+
+ if not companies_response.ok:
+ adapter.error(
+ f"Failed to retrieve company id, reason: {companies_response.text}"
+ )
+ raise Exception("Failed to retrieve company id")
+
+ company_list = companies_response.json()
+ for company in company_list:
+ if company["name"] == auth_parameters.company_name:
+ company_id = company["_id"]
+ break
+ if company_id is None:
+ adapter.error(
+ "Failed to retrieve company id, reason: company does not match with"
+ " an id"
+ )
+ raise Exception("Failed to retrieve company id")
+
+ url = f"{BREEZY_BASE_URL}/company/{company_id}/positions"
+ params = {"state": parameters.state}
+
+ response = requests.get(url, headers=headers, params=params)
+ if not response.ok:
+ adapter.error(f"Failed to read jobs, reason: {response.text}")
+ raise Exception("Failed to read jobs")
+
+ for job in response.json():
+ if mode == Mode.create and not is_within_five_minutes(
+ job["creation_date"], job["updated_date"]
+ ):
+ continue
+ if mode == Mode.update and is_within_five_minutes(
+ job["creation_date"], job["updated_date"]
+ ):
+ continue
+ yield job
+
+ revoke_access_token(access_token)
+
+ return read_jobs
+
+
+def send_profile(
+ adapter: LoggerAdapter,
+ parameters: WriteProfilesParameters,
+ profiles: t.Iterable[t.Dict],
+ access_token: str,
+ company_id: str,
+ candidate_id: str = "",
+):
+ base_url = (
+ f"{BREEZY_BASE_URL}/company/{company_id}/"
+ f"position/{parameters.position_id}/candidate"
+ )
+
+ payload = json.dumps(profiles)
+
+ headers = {"Content-Type": "application/json", "Authorization": f"{access_token}"}
+
+ if candidate_id == "":
+ url = f"{base_url}s/"
+
+ # If the candidate doesn't already exist we "POST" his profile
+ response = requests.post(url, headers=headers, data=payload)
+
+ else:
+ # In case the candidate exists,
+ # we retrieve his id to update his profile with a "PUT" request
+
+ url = f"{base_url}/{candidate_id}"
+
+ url = (
+ f"{BREEZY_BASE_URL}/company/{company_id}/"
+ f"position/{parameters.position_id}/candidate/{candidate_id}"
+ )
+
+ adapter.info(f"Updating id = {candidate_id} profile")
+ response = requests.put(url, headers=headers, data=payload)
+ return response
+
+
+def write_profiles(
+ adapter: LoggerAdapter,
+ auth_parameters: AuthParameters,
+ parameters: WriteProfilesParameters,
+ items: t.Iterable[t.Dict],
+) -> t.List[t.Dict]:
+ failed_profiles = []
+ access_token = get_access_token(adapter, auth_parameters)
+ headers = {"Authorization": access_token}
+
+ company_id = auth_parameters.company_id
+
+ if company_id is None:
+ companies_url = f"{BREEZY_BASE_URL}/companies"
+
+ companies_response = requests.get(companies_url, headers=headers)
+
+ if not companies_response.ok:
+ adapter.error(
+ f"Failed to retrieve company id, reason: {companies_response.text}"
+ )
+ raise Exception("Failed to retrieve company id")
+
+ company_list = companies_response.json()
+ for company in company_list:
+ if company["name"] == auth_parameters.company_name:
+ company_id = company["_id"]
+ break
+ if company_id is None:
+ adapter.error(
+ "Failed to retrieve company id, reason: company does not match with"
+ " an id"
+ )
+ raise Exception("Failed to retrieve company id")
+
+ post_candidate_url = f"{BREEZY_BASE_URL}/company/{company_id}/position/{parameters.position_id}/candidates"
+ for profile in items:
+ resume_url = profile.pop("resume", None)
+ profile.update({"origin": parameters.origin})
+ if parameters.stage_actions_enabled:
+ params = {"stage_actions_enabled": parameters.stage_actions_enabled}
+ else:
+ params = {}
+
+ post_candidate_response = requests.post(
+ post_candidate_url, headers=headers, json=profile, params=params
+ )
+ if not post_candidate_response.ok:
+ adapter.error(
+ f"Couldn't create candidate, reason: {post_candidate_response.text}"
+ )
+ failed_profiles.append(profile)
+ continue
+
+ if resume_url:
+ candidate_id = post_candidate_response.json()["_id"]
+ resume_file_response = requests.get(url=resume_url)
+ resume_file_obj = BytesIO(resume_file_response.content)
+ files = {
+ "file": (
+ "resume.pdf",
+ resume_file_obj,
+ "application/pdf",
+ )
+ }
+ post_resume_url = (
+ f"{BREEZY_BASE_URL}/company/{company_id}/position/"
+ f"{parameters.position_id}/candidate/{candidate_id}/resume"
+ )
+ post_resume_response = requests.post(
+ post_resume_url, headers=headers, files=files
+ )
+ if not post_resume_response.ok:
+ adapter.error(
+ "Failed to attach resume to candidate, reason:"
+ f" {post_resume_response.text}"
+ )
+
+ revoke_access_token(access_token)
+ return failed_profiles
+
+
+def update_profiles(
+ adapter: LoggerAdapter,
+ auth_parameters: AuthParameters,
+ parameters: UpdateProfilesParameters,
+ items: t.Iterable[t.Dict],
+) -> t.List[t.Dict]:
+ failed_profiles = []
+ access_token = get_access_token(adapter, auth_parameters)
+ headers = {"Authorization": access_token}
+
+ company_id = auth_parameters.company_id
+ if company_id is None:
+ companies_url = f"{BREEZY_BASE_URL}/companies"
+
+ companies_response = requests.get(companies_url, headers=headers)
+
+ if not companies_response.ok:
+ adapter.error(
+ f"Failed to retrieve company id, reason: {companies_response.text}"
+ )
+ raise Exception("Failed to retrieve company id")
+
+ company_list = companies_response.json()
+ for company in company_list:
+ if company["name"] == auth_parameters.company_name:
+ company_id = company["_id"]
+ break
+ if company_id is None:
+ adapter.error(
+ "Failed to retrieve company id, reason: company does not match with"
+ " an id"
+ )
+ raise Exception("Failed to retrieve company id")
+
+ base_url = f"{BREEZY_BASE_URL}/company/{company_id}/position/{parameters.position_id}/candidate"
+ for profile in items:
+ candidate_id = profile.pop("id")
+ candidate_work_history = profile.pop("work_history", [])
+ candidate_education = profile.pop("education", [])
+ put_candidate_url = f"{base_url}/{candidate_id}"
+ put_candidate_response = requests.put(
+ put_candidate_url, headers=headers, json=profile
+ )
+ if not put_candidate_response.ok:
+ adapter.error(
+ f"Couldn't create candidate, reason: {put_candidate_response.text}"
+ )
+ failed_profiles.append(profile)
+ continue
+
+ current_educations = [
+ {education["school_name"], education["field_of_study"]}
+ for education in put_candidate_response.json().get("education", [])
+ ]
+ current_work_history = [
+ {work_history["company_name"], work_history["title"]}
+ for work_history in put_candidate_response.json().get("work_history", [])
+ ]
+
+ for education in candidate_education:
+ if {
+ education["school_name"],
+ education["field_of_study"],
+ } in current_educations:
+ continue
+ education_url = f"{put_candidate_url}/education"
+ education_response = requests.put(
+ education_url, headers=headers, json=education
+ )
+ if not education_response.ok:
+ adapter.error(
+ f"Couldn't update education, for candidate {candidate_id}, reason:"
+ f" {education_response.text}"
+ )
+ for work_history in candidate_work_history:
+ if {
+ work_history["company_name"],
+ work_history["title"],
+ } in current_work_history:
+ continue
+ work_history_url = f"{put_candidate_url}/work-history"
+ work_history_response = requests.put(
+ work_history_url, headers=headers, json=work_history
+ )
+ if not work_history_response.ok:
+ adapter.error(
+ f"Couldn't update experience, for candidate {candidate_id}, reason:"
+ f" {work_history_response.text}"
+ )
+
+ revoke_access_token(access_token)
+ return failed_profiles
+
+
+def generic_profiles_read(
+ mode: Mode,
+):
+ def read_profiles(
+ adapter: LoggerAdapter,
+ auth_parameters: AuthParameters,
+ parameters: ReadProfilesParameters,
+ incremental: bool,
+ incremental_token: t.Optional[str],
+ ) -> t.Iterable[t.Dict]:
+ # TODO: add incremental read_mode using page_size, and page as read_from
+ access_token = get_access_token(adapter, auth_parameters)
+ headers = {"Authorization": access_token}
+
+ company_id = auth_parameters.company_id
+ if company_id is None:
+ companies_url = f"{BREEZY_BASE_URL}/companies"
+
+ companies_response = requests.get(companies_url, headers=headers)
+
+ if not companies_response.ok:
+ adapter.error(
+ f"Failed to retrieve company id, reason: {companies_response.text}"
+ )
+ raise Exception("Failed to retrieve company id")
+
+ company_list = companies_response.json()
+ for company in company_list:
+ if company["name"] == auth_parameters.company_name:
+ company_id = company["_id"]
+ break
+ if company_id is None:
+ adapter.error(
+ "Failed to retrieve company id, reason: company does not match with"
+ " an id"
+ )
+ raise Exception("Failed to retrieve company id")
+ if not parameters.position_id:
+ # retrieve all postion ids for all published positions
+ positions_url = (
+ f"{BREEZY_BASE_URL}/company/{company_id}/positions?state=published"
+ )
+
+ positions_response = requests.get(positions_url, headers=headers)
+ if len(positions_response.json()) == 0:
+ adapter.info("No published position found")
+ for position in positions_response.json():
+ position_id = position["_id"]
+ candidates_url = (
+ f"{BREEZY_BASE_URL}/company/{company_id}/position/"
+ f"{position_id}/candidates?sort=updated"
+ )
+ candidates_response = requests.get(candidates_url, headers=headers)
+ if not candidates_response.ok:
+ adapter.error(
+ "Failed to retrieve candidates, reason:"
+ f" {candidates_response.text}"
+ )
+ raise Exception("Failed to retrieve candidates")
+ candidates = candidates_response.json()
+ for candidate in candidates:
+ if mode == Mode.create and not is_within_five_minutes(
+ candidate["creation_date"], candidate["updated_date"]
+ ):
+ continue
+ if mode == Mode.update and is_within_five_minutes(
+ candidate["creation_date"], candidate["updated_date"]
+ ):
+ continue
+ candidate_id = candidate["_id"]
+ full_candidate_url = (
+ f"{BREEZY_BASE_URL}/company/{company_id}/position/"
+ f"{position_id}/candidate/{candidate_id}"
+ )
+ full_candidate_response = requests.get(
+ full_candidate_url, headers=headers
+ )
+ if not full_candidate_response.ok:
+ adapter.error(
+ "Failed to retrieve candidate, reason:"
+ f" {full_candidate_response.text}"
+ )
+ continue
+
+ full_candidate = full_candidate_response.json()
+
+ resume_url = full_candidate.get("resume", {}).get("url")
+ if not resume_url:
+ full_candidate["resume"] = None
+ if resume_url:
+ resume_response = requests.get(resume_url, headers=headers)
+ if not resume_response.ok:
+ adapter.error(
+ "Failed to retrieve resume, reason:"
+ f" {resume_response.text}"
+ )
+ full_candidate["resume"] = None
+ else:
+ full_candidate["resume"] = dict(raw=resume_response.content)
+
+ yield full_candidate
+ else:
+ candidates_url = (
+ f"{BREEZY_BASE_URL}/company/{company_id}/position/"
+ f"{parameters.position_id}/candidates?sort=updated"
+ )
+
+ candidates_response = requests.get(candidates_url, headers=headers)
+ if not candidates_response.ok:
+ adapter.error(
+ f"Failed to retrieve candidates, reason: {candidates_response.text}"
+ )
+ raise Exception("Failed to retrieve candidates")
+ candidates = candidates_response.json()
+ for candidate in candidates:
+ if mode == Mode.create and not is_within_five_minutes(
+ candidate["creation_date"], candidate["updated_date"]
+ ):
+ continue
+ if mode == Mode.update and is_within_five_minutes(
+ candidate["creation_date"], candidate["updated_date"]
+ ):
+ continue
+ candidate_id = candidate["_id"]
+ full_candidate_url = (
+ f"{BREEZY_BASE_URL}/company/{company_id}/position/"
+ f"{parameters.position_id}/candidate/{candidate_id}"
+ )
+ full_candidate_response = requests.get(
+ full_candidate_url, headers=headers
+ )
+ if not full_candidate_response.ok:
+ adapter.error(
+ "Failed to retrieve candidate, reason:"
+ f" {full_candidate_response.text}"
+ )
+ continue
+
+ full_candidate = full_candidate_response.json()
+
+ resume_url = full_candidate.get("resume", {}).get("url")
+ if not resume_url:
+ full_candidate["resume"] = None
+ if resume_url:
+ resume_response = requests.get(resume_url, headers=headers)
+ if not resume_response.ok:
+ adapter.error(
+ f"Failed to retrieve resume, reason: {resume_response.text}"
+ )
+ full_candidate["resume"] = None
+ else:
+ full_candidate["resume"] = dict(raw=resume_response.content)
+
+ yield full_candidate
+ revoke_access_token(access_token)
+
+ return read_profiles
+
+
+# To account for the time differnece between the start of object creation on the platform
+# and its completion
+def is_within_five_minutes(date_str1: str, date_str2: str) -> bool:
+ date1 = datetime.fromisoformat(date_str1.replace("Z", "+00:00"))
+ date2 = datetime.fromisoformat(date_str2.replace("Z", "+00:00"))
+
+ difference = abs(date1 - date2)
+
+ return difference <= timedelta(minutes=5)
+
+
+JobsAisle = Aisle(
+ name=Entity.job,
+ schema=BreezyJobModel,
+ read=ReadOperation(
+ criterias=Criterias(
+ create=ReadJobsParameters,
+ update=ReadJobsParameters,
+ archive=ReadJobsParameters,
+ ),
+ function=merge(
+ create=generic_jobs_read(Mode.create),
+ update=generic_jobs_read(Mode.update),
+ archive=generic_jobs_read(Mode.archive),
+ ),
+ ),
+)
+
+ProfilesAisle = Aisle(
+ name=Entity.profile,
+ schema=BreezyProfileModel,
+ read=ReadOperation(
+ criterias=Criterias(
+ create=ReadProfilesParameters,
+ update=ReadProfilesParameters,
+ archive=ReadProfilesParameters,
+ ),
+ function=merge(
+ create=generic_profiles_read(Mode.create),
+ update=generic_profiles_read(Mode.update),
+ archive=generic_profiles_read(Mode.archive),
+ ),
+ ),
+ write=WriteOperation(
+ criterias=Criterias(
+ create=WriteProfilesParameters, update=UpdateProfilesParameters
+ ),
+ function=merge(create=write_profiles, update=update_profiles),
+ ),
+)
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/connector.py b/src/hrflow_connectors/v2/connectors/breezyhr/connector.py
new file mode 100644
index 000000000..6155281e5
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/connector.py
@@ -0,0 +1,327 @@
+import typing as t
+
+from hrflow_connectors.v2.connectors.breezyhr.utils import (
+ is_valid_url,
+ remove_html_tags,
+)
+from hrflow_connectors.v2.connectors.breezyhr.warehouse import BreezyHrWarehouse
+from hrflow_connectors.v2.core.common import Direction, Entity, Mode
+from hrflow_connectors.v2.core.connector import Connector, ConnectorType, Flow
+
+
+def get_location(location: t.Dict) -> t.Dict:
+ country = location.get("country", {}).get("name")
+ city = location.get("city")
+ address = location.get("name")
+ street_address = location.get("streetAddress", {}).get("custom")
+ geojson = dict(country=country, city=city, text=street_address or address)
+
+ return dict(text=street_address or address, geojson=geojson, lat=None, lng=None)
+
+
+def format_job(breezy_job: t.Dict) -> t.Dict:
+ """
+ Format a Breezy Hr job object into a hrflow job object
+ Returns:
+ HrflowJob: a job object in the hrflow job format
+ """
+ location = get_location(breezy_job.get("location", {}))
+ is_remote = breezy_job.get("location", {}).get("is_remote")
+ remote_details = breezy_job.get("location", {}).get("remote_details")
+ description = remove_html_tags(breezy_job.get("description", ""))
+ cleaned_description = description.replace(" ", " ")
+
+ sections = [
+ dict(
+ name="description",
+ title="Description",
+ description=cleaned_description,
+ ),
+ dict(
+ name="experience",
+ title="Required Experience",
+ description=breezy_job.get("experience", {}).get("name"),
+ ),
+ dict(
+ name="education",
+ title="Required Education",
+ description=breezy_job.get("education"),
+ ),
+ dict(
+ name="category",
+ title="Category",
+ description=breezy_job.get("category", {}).get("name"),
+ ),
+ dict(
+ name="remote",
+ title="Remote Information",
+ description=remote_details,
+ ),
+ ]
+ t = lambda name, value: dict(name=name, value=value)
+ tags = [
+ t("type", breezy_job.get("type", {}).get("name")),
+ t("experience", breezy_job.get("experience", {}).get("name")),
+ t("education", breezy_job.get("education")),
+ t("department", breezy_job.get("department")),
+ t("requisition_id", breezy_job.get("requisition_id")),
+ t("category", breezy_job.get("category", {}).get("name")),
+ t("candidate_type", breezy_job.get("candidate_type")),
+ t("isremote", is_remote),
+ t("remote_details", remote_details),
+ t("creator_id", breezy_job.get("creator_id")),
+ t("breezy_hr_tags", breezy_job.get("tags")),
+ ]
+
+ hrflow_job = dict(
+ name=breezy_job.get("name"),
+ reference=breezy_job.get("_id"),
+ summary=cleaned_description,
+ location=location,
+ sections=sections,
+ tags=tags,
+ created_at=breezy_job.get("creation_date"),
+ updated_at=breezy_job.get("updated_date"),
+ )
+
+ return hrflow_job
+
+
+def format_archive_in_hrflow(breezy_element: t.Dict) -> t.Dict:
+ return dict(reference=breezy_element.get("_id"))
+
+
+def format_profile(hrflow_profile: t.Dict) -> t.Dict:
+ """
+ Format a Hrflow profile object into a breezy hr profile object
+ Args:
+ data (HrflowProfile): Hrflow Profile to format
+ Returns:
+ BreezyProfileModel: a BreezyHr formatted profile object
+ """
+
+ info = hrflow_profile.get("info", {})
+
+ work_history = []
+ for experience in hrflow_profile.get("experiences", []):
+ formatted_experience = dict()
+ if experience.get("company") not in ["", None]:
+ formatted_experience["company_name"] = experience.get("company")
+ else:
+ formatted_experience["company_name"] = "Undefined"
+ formatted_experience["title"] = experience.get("title")
+ formatted_experience["summary"] = experience.get("description")
+ if experience.get("date_start") is not None:
+ date_start = experience["date_start"]
+ formatted_experience["start_year"] = int(date_start[:4])
+ formatted_experience["start_month"] = int(date_start[5:7])
+ if experience.get("date_end") is not None:
+ date_end = experience["date_end"]
+ formatted_experience["end_year"] = int(date_end[:4])
+ formatted_experience["end_month"] = int(date_end[5:7])
+ work_history.append(formatted_experience)
+
+ educations = []
+ for education in hrflow_profile.get("educations", []):
+ formatted_education = dict()
+ if education.get("school") == "":
+ education["school"] = "Undefined"
+ formatted_education["school_name"] = education.get("school")
+ formatted_education["field_of_study"] = education.get("title")
+ if education.get("date_start") is not None:
+ date_start = education["date_start"]
+ formatted_education["start_year"] = int(date_start[:4])
+ if education.get("date_end") is not None:
+ date_end = education["date_end"]
+ formatted_education["end_year"] = int(date_end[:4])
+ educations.append(formatted_education)
+
+ social_profiles = {}
+ if info.get("urls"):
+ for url in info.get("urls"):
+ type = url.get("type")
+ link = url.get("url")
+ if type and link:
+ if type == "from_resume" or not is_valid_url(link):
+ continue
+ social_profiles.update({type: link})
+
+ # add profile skills to tags
+ tags = []
+ skills = hrflow_profile.get("skills")
+ if skills:
+ tags = [skill.get("name") for skill in skills]
+
+ # add resume to profile
+ attachments = hrflow_profile.get("attachments", [])
+ resume_url = next(
+ (
+ attachment
+ for attachment in attachments
+ if attachment.get("type") == "resume"
+ ),
+ {},
+ ).get("public_url")
+ breezy_profile = dict(
+ name=info.get("full_name"),
+ email_address=info.get("email"),
+ phone_number=info.get("phone"),
+ address=info.get("location", {}).get("text"),
+ summary=info.get("summary"),
+ work_history=work_history,
+ education=educations,
+ social_profiles=social_profiles,
+ tags=tags,
+ resume=resume_url,
+ )
+ return breezy_profile
+
+
+def format_profile_for_update(hrflow_profile: t.Dict) -> t.Dict:
+ breezy_profile = format_profile(hrflow_profile)
+ breezy_profile["id"] = hrflow_profile.get("reference")
+ return breezy_profile
+
+
+def format_date_to_iso(date):
+ year = date.get("year")
+ month = date.get("month")
+ day = date.get("day")
+ # Check if the date is complete, i.e., year, month, and day are all present
+ if year is not None and month is not None and day is not None:
+ return f"{year:04d}-{month:02d}-{day:02d}"
+ elif year is not None and month is not None:
+ return f"{year:04d}-{month:02d}"
+ elif year is not None:
+ return f"{year:04d}"
+ else:
+ return None
+
+
+def format_candidate(breezy_profile: t.Dict) -> t.Dict:
+ """
+ Format a Breezy profile object into a Hrflow profile object
+ Args:
+ data (BreezyProfileModel): Breezy Profile to format
+ Returns:
+ HrFlowProfile: a Hrflow formatted profile object
+ """
+ info = dict(
+ full_name=breezy_profile["name"],
+ first_name=breezy_profile["name"].split(" ")[0],
+ last_name=breezy_profile["name"].split(" ")[-1],
+ email=breezy_profile["email_address"],
+ phone=breezy_profile["phone_number"],
+ urls=[
+ dict(type=social_profile["type"], url=social_profile["url"])
+ for social_profile in breezy_profile.get("social_profiles", [])
+ ],
+ summary=breezy_profile["summary"],
+ location={"text": breezy_profile.get("address", ""), "lat": None, "lng": None},
+ )
+
+ educations = []
+ for education in breezy_profile.get("education", []):
+ formatted_education = dict()
+ formatted_education["school"] = education.get("school_name")
+ degree = education.get("degree")
+ field_of_study = education.get("field_of_study")
+ if degree or field_of_study:
+ formatted_education["title"] = (
+ (degree if degree else "")
+ + " "
+ + (field_of_study if field_of_study else "")
+ ).strip()
+ formatted_education["description"] = (
+ formatted_education["title"] + " at " + formatted_education["school"]
+ )
+ formatted_education["date_start"] = None
+ formatted_education["date_end"] = None
+ if education.get("start_date") is not None:
+ formatted_education["date_start"] = format_date_to_iso(
+ education["start_date"]
+ )
+ if education.get("end_date") is not None:
+ formatted_education["date_end"] = format_date_to_iso(education["end_date"])
+ formatted_education["location"] = {"text": None, "lat": None, "lng": None}
+ educations.append(formatted_education)
+
+ experiences = []
+ for experience in breezy_profile.get("work_history", []):
+ formatted_experience = dict()
+ formatted_experience["company"] = experience.get("company_name")
+
+ formatted_experience["title"] = experience.get("title")
+ formatted_experience["description"] = experience.get("summary")
+ formatted_experience["date_start"] = None
+ formatted_experience["date_end"] = None
+ if experience.get("start_date") is not None:
+ formatted_experience["date_start"] = format_date_to_iso(
+ experience["start_date"]
+ )
+ if experience.get("end_date") is not None:
+ formatted_experience["date_end"] = format_date_to_iso(
+ experience["end_date"]
+ )
+ formatted_experience["location"] = {"text": None, "lat": None, "lng": None}
+ experiences.append(formatted_experience)
+
+ tags = []
+ t = lambda name, value: dict(name=name, value=value)
+ tags.append(t("breezy_hr_tags", breezy_profile.get("tags")))
+ tags.append(t("headline", breezy_profile.get("headline")))
+ tags.append(t("origin", breezy_profile.get("origin")))
+ tags.append(t("source", breezy_profile.get("source", {}).get("name")))
+ tags.append(t("sourced_by", breezy_profile.get("sourced_by")))
+ tags.append(t("stage", breezy_profile.get("stage", {}).get("name")))
+ tags.append(
+ t("overall_score", breezy_profile.get("overall_score", {}).get("average_score"))
+ )
+ hrflow_profile = dict(
+ reference=breezy_profile.get("_id"),
+ info=info,
+ created_at=breezy_profile.get("creation_date"),
+ updated_at=breezy_profile.get("updated_date"),
+ experiences=experiences,
+ educations=educations,
+ skills=[],
+ tags=tags,
+ resume=breezy_profile.get("resume"),
+ )
+
+ return hrflow_profile
+
+
+BreezyHR = Connector(
+ name="Breezy HR",
+ type=ConnectorType.ATS,
+ subtype="breezyhr",
+ description=(
+ "Breezyhr is an end-to-end recruiting software "
+ "to help you attract & hire great employees with less effort"
+ ),
+ url="https://breezy.hr/",
+ warehouse=BreezyHrWarehouse,
+ flows=(
+ Flow(Mode.create, Entity.job, Direction.inbound, format=format_job),
+ Flow(Mode.update, Entity.job, Direction.inbound, format=format_job),
+ Flow(
+ Mode.archive, Entity.job, Direction.inbound, format=format_archive_in_hrflow
+ ),
+ Flow(Mode.create, Entity.profile, Direction.inbound, format=format_candidate),
+ Flow(Mode.update, Entity.profile, Direction.inbound, format=format_candidate),
+ Flow(Mode.create, Entity.profile, Direction.outbound, format=format_profile),
+ Flow(
+ Mode.update,
+ Entity.profile,
+ Direction.outbound,
+ format=format_profile_for_update,
+ ),
+ Flow(
+ Mode.archive,
+ Entity.profile,
+ Direction.inbound,
+ format=format_archive_in_hrflow,
+ ),
+ ),
+)
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/docs/pull_job_list.md b/src/hrflow_connectors/v2/connectors/breezyhr/docs/pull_job_list.md
new file mode 100644
index 000000000..fcde3590f
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/docs/pull_job_list.md
@@ -0,0 +1,71 @@
+# Pull job list
+`BreezyHRJobWarehouse` :arrow_right: `HrFlow.ai Jobs`
+
+Retrieves all jobs via the ***BreezyHR*** API and send them to a ***Hrflow.ai Board***.
+
+
+
+## Action Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `logics` | `typing.List[typing.Callable[[typing.Dict], typing.Optional[typing.Dict]]]` | [] | List of logic functions |
+| `format` | `typing.Callable[[typing.Dict], typing.Dict]` | [`format_jobs`](../connector.py#L31) | Formatting function |
+| `read_mode` | `str` | ReadMode.sync | If 'incremental' then `read_from` of the last run is given to Origin Warehouse during read. **The actual behavior depends on implementation of read**. In 'sync' mode `read_from` is neither fetched nor given to Origin Warehouse during read. |
+
+## Source Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `email` :red_circle: | `str` | None | email |
+| `password` :red_circle: | `str` | None | password |
+| `company_id` | `str` | None | ID of company to pull jobs from in Breezy HR database associated with the authenticated user |
+| `company_name` | `str` | None | [β οΈ Requiered if company_id is not specified], the company associated with the authenticated user |
+
+## Destination Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `api_secret` :red_circle: | `str` | None | X-API-KEY used to access HrFlow.ai API |
+| `api_user` :red_circle: | `str` | None | X-USER-EMAIL used to access HrFlow.ai API |
+| `board_key` :red_circle: | `str` | None | HrFlow.ai board key |
+| `sync` | `bool` | True | When enabled only pushed jobs will remain in the board |
+| `update_content` | `bool` | False | When enabled jobs already present in the board are updated |
+| `enrich_with_parsing` | `bool` | False | When enabled jobs are enriched with HrFlow.ai parsing |
+
+:red_circle: : *required*
+
+## Example
+
+```python
+import logging
+from hrflow_connectors import BreezyHR
+from hrflow_connectors.core import ReadMode
+
+
+logging.basicConfig(level=logging.INFO)
+
+
+BreezyHR.pull_job_list(
+ workflow_id="some_string_identifier",
+ action_parameters=dict(
+ logics=[],
+ format=lambda *args, **kwargs: None # Put your code logic here,
+ read_mode=ReadMode.sync,
+ ),
+ origin_parameters=dict(
+ email="your_email",
+ password="your_password",
+ company_id="your_company_id",
+ company_name="your_company_name",
+ ),
+ target_parameters=dict(
+ api_secret="your_api_secret",
+ api_user="your_api_user",
+ board_key="your_board_key",
+ sync=True,
+ update_content=False,
+ enrich_with_parsing=False,
+ )
+)
+```
\ No newline at end of file
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/docs/pull_profile_list.md b/src/hrflow_connectors/v2/connectors/breezyhr/docs/pull_profile_list.md
new file mode 100644
index 000000000..738b15af6
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/docs/pull_profile_list.md
@@ -0,0 +1,71 @@
+# Pull profile list
+`BreezyHRWarehouse` :arrow_right: `HrFlow.ai Profiles`
+
+Retrieves all profiles via the ***BreezyHR*** API and send them to a ***Hrflow.ai Source***.
+
+
+
+## Action Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `logics` | `typing.List[typing.Callable[[typing.Dict], typing.Optional[typing.Dict]]]` | [] | List of logic functions |
+| `format` | `typing.Callable[[typing.Dict], typing.Dict]` | [`format_candidate`](../connector.py#L208) | Formatting function |
+| `read_mode` | `str` | ReadMode.sync | If 'incremental' then `read_from` of the last run is given to Origin Warehouse during read. **The actual behavior depends on implementation of read**. In 'sync' mode `read_from` is neither fetched nor given to Origin Warehouse during read. |
+
+## Source Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `email` :red_circle: | `str` | None | email |
+| `password` :red_circle: | `str` | None | password |
+| `company_id` | `str` | None | ID of company to pull jobs from in Breezy HR database associated with the authenticated user |
+| `company_name` | `str` | None | [β οΈ Requiered if company_id is not specified], the company associated with the authenticated user |
+| `position_id` :red_circle: | `str` | None | Id of the position to create a new candidate for |
+
+## Destination Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `api_secret` :red_circle: | `str` | None | X-API-KEY used to access HrFlow.ai API |
+| `api_user` :red_circle: | `str` | None | X-USER-EMAIL used to access HrFlow.ai API |
+| `source_key` :red_circle: | `str` | None | HrFlow.ai source key |
+| `edit` | `bool` | False | When enabled the profile must exist in the source |
+| `only_edit_fields` :red_circle: | `typing.List[str]` | None | List of attributes to use for the edit operation e.g. ['tags', 'metadatas'] |
+
+:red_circle: : *required*
+
+## Example
+
+```python
+import logging
+from hrflow_connectors import BreezyHR
+from hrflow_connectors.core import ReadMode
+
+
+logging.basicConfig(level=logging.INFO)
+
+
+BreezyHR.pull_profile_list(
+ workflow_id="some_string_identifier",
+ action_parameters=dict(
+ logics=[],
+ format=lambda *args, **kwargs: None # Put your code logic here,
+ read_mode=ReadMode.sync,
+ ),
+ origin_parameters=dict(
+ email="your_email",
+ password="your_password",
+ company_id="your_company_id",
+ company_name="your_company_name",
+ position_id="your_position_id",
+ ),
+ target_parameters=dict(
+ api_secret="your_api_secret",
+ api_user="your_api_user",
+ source_key="your_source_key",
+ edit=False,
+ only_edit_fields=***,
+ )
+)
+```
\ No newline at end of file
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/docs/push_profile_list.md b/src/hrflow_connectors/v2/connectors/breezyhr/docs/push_profile_list.md
new file mode 100644
index 000000000..355e85cb7
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/docs/push_profile_list.md
@@ -0,0 +1,73 @@
+# Push profile list
+`HrFlow.ai Profiles` :arrow_right: `BreezyHRWarehouse`
+
+Push all profiles from ***Hrflow.ai Source*** via ***BreezyHR*** API and send them to a ***BreezyHR***.
+
+
+
+## Action Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `logics` | `typing.List[typing.Callable[[typing.Dict], typing.Optional[typing.Dict]]]` | [] | List of logic functions |
+| `format` | `typing.Callable[[typing.Dict], typing.Dict]` | [`format_profile`](../connector.py#L94) | Formatting function |
+| `read_mode` | `str` | ReadMode.sync | If 'incremental' then `read_from` of the last run is given to Origin Warehouse during read. **The actual behavior depends on implementation of read**. In 'sync' mode `read_from` is neither fetched nor given to Origin Warehouse during read. |
+
+## Source Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `api_secret` :red_circle: | `str` | None | X-API-KEY used to access HrFlow.ai API |
+| `api_user` :red_circle: | `str` | None | X-USER-EMAIL used to access HrFlow.ai API |
+| `source_key` :red_circle: | `str` | None | HrFlow.ai source key |
+| `profile_key` :red_circle: | `str` | None | HrFlow.ai profile key |
+
+## Destination Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `email` :red_circle: | `str` | None | email |
+| `password` :red_circle: | `str` | None | password |
+| `company_id` | `str` | None | ID of company to pull jobs from in Breezy HR database associated with the authenticated user
+ [β οΈ Requiered if company_name is not specified] |
+| `company_name` | `str` | None | the company associated with the authenticated user
+ [β οΈ Requiered if company_id is not specified] |
+| `position_id` :red_circle: | `str` | None | Id of the position to create a new candidate for |
+| `origin` | `str` | sourced | will indicate in Breezy if the candidate should be marked as sourced or applied |
+
+:red_circle: : *required*
+
+## Example
+
+```python
+import logging
+from hrflow_connectors import BreezyHR
+from hrflow_connectors.core import ReadMode
+
+
+logging.basicConfig(level=logging.INFO)
+
+
+BreezyHR.push_profile_list(
+ workflow_id="some_string_identifier",
+ action_parameters=dict(
+ logics=[],
+ format=lambda *args, **kwargs: None # Put your code logic here,
+ read_mode=ReadMode.sync,
+ ),
+ origin_parameters=dict(
+ api_secret="your_api_secret",
+ api_user="your_api_user",
+ source_key="your_source_key",
+ profile_key="your_profile_key",
+ ),
+ target_parameters=dict(
+ email="your_email",
+ password="your_password",
+ company_id="your_company_id",
+ company_name="your_company_name",
+ position_id="your_position_id",
+ origin="sourced",
+ )
+)
+```
\ No newline at end of file
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/logo.jpg b/src/hrflow_connectors/v2/connectors/breezyhr/logo.jpg
new file mode 100644
index 000000000..f0eed272e
Binary files /dev/null and b/src/hrflow_connectors/v2/connectors/breezyhr/logo.jpg differ
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/archive_jobs_in_hrflow.json b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/archive_jobs_in_hrflow.json
new file mode 100644
index 000000000..018c86b68
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/archive_jobs_in_hrflow.json
@@ -0,0 +1,3 @@
+{
+ "reference": "?._id"
+}
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/archive_profiles_in_hrflow.json b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/archive_profiles_in_hrflow.json
new file mode 100644
index 000000000..018c86b68
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/archive_profiles_in_hrflow.json
@@ -0,0 +1,3 @@
+{
+ "reference": "?._id"
+}
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/create_jobs_in_hrflow.json b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/create_jobs_in_hrflow.json
new file mode 100644
index 000000000..4f08dba1b
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/create_jobs_in_hrflow.json
@@ -0,0 +1,90 @@
+{
+ "name": "?.name",
+ "reference": "?._id",
+ "summary": "?.description | $sub('<[^<]+?>', '') | $sub(' ', ' ')",
+ "location": {
+ "text": "?.location?.streetAddress?.custom ?? .location.streetAddress.custom: .location.name",
+ "geojson": {
+ "country": "?.location?.country?.name",
+ "city": "?.location?.city",
+ "text": "?.location?.streetAddress?.custom ?? .location.streetAddress.custom: .location.name"
+ },
+ "lat": null,
+ "lng": null
+ },
+ "sections": [
+ {
+ "name": "description",
+ "title": "description",
+ "description": "?.description | $sub('<[^<]+?>', '') | $sub(' ', ' ')"
+ },
+ {
+ "name": "experience",
+ "title": "Required Experience",
+ "description": "?.experience?.name"
+ },
+ {
+ "name": "education",
+ "title": "Required Education",
+ "description": "?.education?.name"
+ },
+ {
+ "name": "category",
+ "title": "Category",
+ "description": "?.category?.name"
+ },
+ {
+ "name": "remote",
+ "title": "Remote Information",
+ "description": "?.location?.remote_details"
+ }
+ ],
+ "tags": [
+ {
+ "name": "type",
+ "value": "?.type?.name"
+ },
+ {
+ "name": "experience",
+ "value": "?.experience?.name"
+ },
+ {
+ "name": "education",
+ "value": "?.education"
+ },
+ {
+ "name": "department",
+ "value": "?.department"
+ },
+ {
+ "name": "requisition_id",
+ "value": "?.requisition_id"
+ },
+ {
+ "name": "category",
+ "value": "?.category?.name"
+ },
+ {
+ "name": "candidate_type",
+ "value": "?.candidate_type"
+ },
+ {
+ "name": "isremote",
+ "value": "?.location?.is_remote"
+ },
+ {
+ "name": "remote_details",
+ "value": "?.location?.remote_details"
+ },
+ {
+ "name": "creator_id",
+ "value": "?.creator_id"
+ },
+ {
+ "name": "breezy_hr_tags",
+ "value": "?.tags"
+ }
+ ],
+ "created_at": "?.creation_date",
+ "updated_at": "?.updated_date"
+}
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/create_profiles_in_breezyhr.json b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/create_profiles_in_breezyhr.json
new file mode 100644
index 000000000..1296845b1
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/create_profiles_in_breezyhr.json
@@ -0,0 +1,11 @@
+{
+ "name": "?.info?.full_name",
+ "email_address": "?.info?.email",
+ "phone_number": "?.info?.phone",
+ "address": "?.info?.location?.text",
+ "summary": "?.info?.summary",
+ "work_history": "?.experiences | $map({company_name: ?.company >> 'Undefined', title: ?.title, summary: ?.description, start_year: ?.date_start != null ?? .date_start | $slice(0, 3) | $int, start_month: ?.date_start != null ?? .date_start | $slice(4,7) | $int, end_year: ?.date_end != null ?? .date_end | $slice(0, 3) | $int, end_month: ?.date_end != null ?? .date_end | $slice(4,7) | $int})",
+ "education": "?.educations | $map({school_name: ?.school >> 'Undefined', field_of_study: ?.title, start_year: ?.date_start != null ?? .date_start | $slice(0, 3) | $int, end_year: ?.date_end != null ?? .date_end | $slice(0, 3) | $int})",
+ "social_profiles": ".info?.urls >> [] | $map({.type: .url | $sub('(^https?://)(.*)', 'https://$2')})",
+ "tags": "?.skills >> [] | $map(?.name)"
+}
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/create_profiles_in_hrflow.json b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/create_profiles_in_hrflow.json
new file mode 100644
index 000000000..48f490e80
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/create_profiles_in_hrflow.json
@@ -0,0 +1,50 @@
+{
+ "reference": "?._id",
+ "info": {
+ "full_name": ".name",
+ "first_name": ".name | $split(' ') | .[0]",
+ "last_name": ".name | $split(' ') | .[-1]",
+ "email": ".email_address",
+ "phone": ".phone_number",
+ "urls": "?.social_profiles || [] | $map({type: .type, url: .url})",
+ "summary": ".summary",
+ "location": {
+ "text": "?.address",
+ "lat": null,
+ "lng": null
+ }
+ },
+ "created_at": "?.creation_date",
+ "updated_at": "?.updated_date",
+ "experiences": "?.work_history || [] | $map({company: ?.company_name, title: ?.title, description: ?.summary, date_start: ?.start_date.year != null and ?.start_date.month != null and ?.start_date.day != null ?? .start_date | $concat(.year, '-', $concat('0', .month) | $slice(-2), '-', $concat('0', .day) | $slice(-2)) : null, date_end: ?.end_date.year != null and ?.end_date.month != null and ?.end_date.day != null ?? .end_date | $concat(.year, '-', $concat('0', .month) | $slice(-2), '-', $concat('0', .day) | $slice(-2)) : null, location: {text: null, lat: null, lng: null}})",
+ "educations": "?.education || [] | $map({school: ?.school_name, title: $concat(?.degree || '', ' ', ?.field_of_study || '')| $strip, description: $concat(?.degree || '', ' ', ?.field_of_study || '', ' at ', ?.school_name || '')| $strip, date_start: ?.start_date.year != null and ?.start_date.month != null and ?.start_date.day != null ?? .start_date | $concat(.year, '-', $concat('0', .month) | $slice(-2), '-', $concat('0', .day) | $slice(-2)) : null, date_end: ?.end_date.year != null and ?.end_date.month != null and ?.end_date.day != null ?? .end_date | $concat(.year, '-', $concat('0', .month) | $slice(-2), '-', $concat('0', .day) | $slice(-2)) : null, location: {text: null, lat: null, lng: null}})",
+ "skills": [],
+ "tags": [
+ {
+ "name": "breezy_hr_tags",
+ "value": "?.tags"
+ },
+ {
+ "name": "headline",
+ "value": "?.headline"
+ },
+ {
+ "name": "origin",
+ "value": "?.origin"
+ },
+ {
+ "name": "source",
+ "value": "?.source?.name"
+ },
+ {
+ "name": "sourced_by",
+ "value": "?.sourced_by"
+ },
+ { "name": "stage", "value": "?.stage?.name" },
+ {
+ "name": "overall_score",
+ "value": "?.overall_score?.average_score"
+ }
+ ],
+ "resume": "?.resume"
+}
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/update_jobs_in_hrflow.json b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/update_jobs_in_hrflow.json
new file mode 100644
index 000000000..4f08dba1b
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/update_jobs_in_hrflow.json
@@ -0,0 +1,90 @@
+{
+ "name": "?.name",
+ "reference": "?._id",
+ "summary": "?.description | $sub('<[^<]+?>', '') | $sub(' ', ' ')",
+ "location": {
+ "text": "?.location?.streetAddress?.custom ?? .location.streetAddress.custom: .location.name",
+ "geojson": {
+ "country": "?.location?.country?.name",
+ "city": "?.location?.city",
+ "text": "?.location?.streetAddress?.custom ?? .location.streetAddress.custom: .location.name"
+ },
+ "lat": null,
+ "lng": null
+ },
+ "sections": [
+ {
+ "name": "description",
+ "title": "description",
+ "description": "?.description | $sub('<[^<]+?>', '') | $sub(' ', ' ')"
+ },
+ {
+ "name": "experience",
+ "title": "Required Experience",
+ "description": "?.experience?.name"
+ },
+ {
+ "name": "education",
+ "title": "Required Education",
+ "description": "?.education?.name"
+ },
+ {
+ "name": "category",
+ "title": "Category",
+ "description": "?.category?.name"
+ },
+ {
+ "name": "remote",
+ "title": "Remote Information",
+ "description": "?.location?.remote_details"
+ }
+ ],
+ "tags": [
+ {
+ "name": "type",
+ "value": "?.type?.name"
+ },
+ {
+ "name": "experience",
+ "value": "?.experience?.name"
+ },
+ {
+ "name": "education",
+ "value": "?.education"
+ },
+ {
+ "name": "department",
+ "value": "?.department"
+ },
+ {
+ "name": "requisition_id",
+ "value": "?.requisition_id"
+ },
+ {
+ "name": "category",
+ "value": "?.category?.name"
+ },
+ {
+ "name": "candidate_type",
+ "value": "?.candidate_type"
+ },
+ {
+ "name": "isremote",
+ "value": "?.location?.is_remote"
+ },
+ {
+ "name": "remote_details",
+ "value": "?.location?.remote_details"
+ },
+ {
+ "name": "creator_id",
+ "value": "?.creator_id"
+ },
+ {
+ "name": "breezy_hr_tags",
+ "value": "?.tags"
+ }
+ ],
+ "created_at": "?.creation_date",
+ "updated_at": "?.updated_date"
+}
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/update_profiles_in_breezyhr.json b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/update_profiles_in_breezyhr.json
new file mode 100644
index 000000000..69fc2b7b1
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/update_profiles_in_breezyhr.json
@@ -0,0 +1,12 @@
+{
+ "id": "?.reference",
+ "name": "?.info?.full_name",
+ "email_address": "?.info?.email",
+ "phone_number": "?.info?.phone",
+ "address": "?.info?.location?.text",
+ "summary": "?.info?.summary",
+ "work_history": "?.experiences | $map({company_name: ?.company >> 'Undefined', title: ?.title, summary: ?.description, start_year: ?.date_start != null ?? .date_start | $slice(0, 3) | $int, start_month: ?.date_start != null ?? .date_start | $slice(4,7) | $int, end_year: ?.date_end != null ?? .date_end | $slice(0, 3) | $int, end_month: ?.date_end != null ?? .date_end | $slice(4,7) | $int})",
+ "education": "?.educations | $map({school_name: ?.school >> 'Undefined', field_of_study: ?.title, start_year: ?.date_start != null ?? .date_start | $slice(0, 3) | $int, end_year: ?.date_end != null ?? .date_end | $slice(0, 3) | $int})",
+ "social_profiles": ".info?.urls >> [] | $map({.type: .url | $sub('(^https?://)(.*)', 'https://$2')})",
+ "tags": "?.skills >> [] | $map(?.name)"
+}
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/update_profiles_in_hrflow.json b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/update_profiles_in_hrflow.json
new file mode 100644
index 000000000..f2f9f56de
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/mappings/format/update_profiles_in_hrflow.json
@@ -0,0 +1,50 @@
+{
+ "reference": "?._id",
+ "info": {
+ "full_name": ".name",
+ "first_name": ".name | $split(' ') | .[0]",
+ "last_name": ".name | $split(' ') | .[-1]",
+ "email": ".email_address",
+ "phone": ".phone_number",
+ "urls": "?.social_profiles || [] | $map({type: .type, url: .url})",
+ "summary": ".summary",
+ "location": {
+ "text": ".address",
+ "lat": null,
+ "lng": null
+ }
+ },
+ "created_at": "?.creation_date",
+ "updated_at": "?.updated_date",
+ "experiences": "?.work_history || [] | $map({company: ?.company_name, title: ?.title, description: ?.summary, date_start: ?.start_date.year != null and ?.start_date.month != null and ?.start_date.day != null ?? .start_date | $concat(.year, '-', $concat('0', .month) | $slice(-2), '-', $concat('0', .day) | $slice(-2)) : null, date_end: ?.end_date.year != null and ?.end_date.month != null and ?.end_date.day != null ?? .end_date | $concat(.year, '-', $concat('0', .month) | $slice(-2), '-', $concat('0', .day) | $slice(-2)) : null, location: {text: null, lat: null, lng: null}})",
+ "educations": "?.education || [] | $map({school: ?.school_name, title: $concat(?.degree || '', ' ', ?.field_of_study || '')| $strip, description: $concat(?.degree || '', ' ', ?.field_of_study || '', ' at ', ?.school_name || '')| $strip, date_start: ?.start_date.year != null and ?.start_date.month != null and ?.start_date.day != null ?? .start_date | $concat(.year, '-', $concat('0', .month) | $slice(-2), '-', $concat('0', .day) | $slice(-2)) : null, date_end: ?.end_date.year != null and ?.end_date.month != null and ?.end_date.day != null ?? .end_date | $concat(.year, '-', $concat('0', .month) | $slice(-2), '-', $concat('0', .day) | $slice(-2)) : null, location: {text: null, lat: null, lng: null}})",
+ "skills": [],
+ "tags": [
+ {
+ "name": "breezy_hr_tags",
+ "value": "?.tags"
+ },
+ {
+ "name": "headline",
+ "value": "?.headline"
+ },
+ {
+ "name": "origin",
+ "value": "?.origin"
+ },
+ {
+ "name": "source",
+ "value": "?.source?.name"
+ },
+ {
+ "name": "sourced_by",
+ "value": "?.sourced_by"
+ },
+ { "name": "stage", "value": "?.stage?.name" },
+ {
+ "name": "overall_score",
+ "value": "?.overall_score?.average_score"
+ }
+ ],
+ "resume": "?.resume"
+}
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/notebooks/.gitkeep b/src/hrflow_connectors/v2/connectors/breezyhr/notebooks/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/schemas.py b/src/hrflow_connectors/v2/connectors/breezyhr/schemas.py
new file mode 100644
index 000000000..f56eea7b1
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/schemas.py
@@ -0,0 +1,112 @@
+from typing import Any, Dict, List, Optional
+
+from msgspec import Meta, Struct
+from typing_extensions import Annotated
+
+
+class Type(Struct):
+ id: str
+ name: str
+
+
+class Experience(Struct):
+ id: str
+ name: str
+
+
+class Country(Struct):
+ name: str
+ id: str
+
+
+class Location(Struct):
+ country: Country
+ city: str
+ is_remote: Optional[bool]
+ name: str
+
+
+class Category(Struct):
+ id: str
+ name: str
+
+
+class ApplicationForm(Struct):
+ name: str
+ headline: str
+ summary: str
+ profile_photo: str
+ address: str
+ email_address: str
+ phone_number: str
+ resume: str
+ work_history: str
+ education: str
+ cover_letter: str
+ questionnaire_in_experience: bool
+
+
+class BreezyJobModel(Struct):
+ _id: Annotated[str, Meta(description="position id")]
+ type: Annotated[Type, Meta(description="job type")]
+ state: Annotated[
+ str,
+ Meta(
+ description="state of the position posting, published or internal and so on"
+ ),
+ ]
+ name: Annotated[str, Meta(description="job title")]
+ friendly_id: Annotated[
+ str,
+ Meta(description="another id of the job which combines its title and its id"),
+ ]
+ experience: Optional[Experience]
+ location: Location
+ education: str
+ department: str
+ description: Annotated[str, Meta(description="Job category")]
+ category: Category
+ application_form: Annotated[
+ Optional[ApplicationForm], Meta(description="job Application for")
+ ]
+ creator_id: Optional[str]
+ creation_date: str
+ updated_date: str
+ all_users: List[str]
+ all_admins: List[str]
+ candidate_type: str
+ tags: List
+ org_type: str
+
+
+class WorkHistoryItem(Struct):
+ company_name: str
+ title: str
+ summary: str
+ start_month: Optional[int]
+ start_year: Optional[int]
+ end_month: Optional[int]
+ end_year: Optional[int]
+
+
+class EducationItem(Struct):
+ school_name: str
+ field_of_study: str
+ start_year: Optional[int]
+ end_year: Optional[int]
+
+
+class BreezyProfileModel(Struct):
+ name: str
+ email_address: str
+ phone_number: str
+ summary: str
+ tags: Optional[List[str]]
+ source: Optional[str]
+ origin: Optional[str]
+ address: str
+ work_history: List[WorkHistoryItem]
+ education: List[EducationItem]
+ social_profiles: Optional[List[str]]
+ custom_attributes: Optional[List[Dict[str, Any]]]
+ cover_letter: Optional[str]
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/test-config.yaml b/src/hrflow_connectors/v2/connectors/breezyhr/test-config.yaml
new file mode 100644
index 000000000..4fd42883e
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/test-config.yaml
@@ -0,0 +1,239 @@
+warehouse:
+ BreezyHRJobWarehouse:
+ read:
+ - parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_id : $__COMPANY_ID
+ company_name : $__COMPANY_NAME
+
+ - parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_id : $__COMPANY_ID
+
+ - parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_name : $__COMPANY_NAME
+
+actions:
+ pull_jobs:
+ - id: valid_parameters
+ origin_parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_id : $__COMPANY_ID
+ company_name : $__COMPANY_NAME
+ target_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ board_key: $__BOARD_KEY
+ status: success
+
+ - id: valid_parameters_without_company_name
+ origin_parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_id : $__COMPANY_ID
+ target_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ board_key: $__BOARD_KEY
+ status: success
+
+ - id: valid_parameters_without_company_id
+ origin_parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_name : $__COMPANY_NAME
+ target_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ board_key: $__BOARD_KEY
+ status: success
+
+ - id: invalid_parameters_missing_company_id_or_name
+ origin_parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ target_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ board_key: $__BOARD_KEY
+ status: fatal
+
+ - id: invalid_parameters_email
+ origin_parameters:
+ email: invalid
+ password: $__PASSWORD
+ company_name : $__COMPANY_NAME
+ target_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ board_key: $__BOARD_KEY
+ status: fatal
+
+ - id: missing_parameters_email
+ origin_parameters:
+ password: $__PASSWORD
+ company_name : $__COMPANY_NAME
+ target_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ board_key: $__BOARD_KEY
+ status: fatal
+
+ - id: invalid_parameters_password
+ origin_parameters:
+ email: $__EMAIL
+ password: invalid
+ company_name : $__COMPANY_NAME
+ target_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ board_key: $__BOARD_KEY
+ status: fatal
+
+ - id: missing_parameters_password
+ origin_parameters:
+ email: $__EMAIL
+ company_name : $__COMPANY_NAME
+ target_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ board_key: $__BOARD_KEY
+ status: fatal
+
+ - id: invalid_parameters_company_id
+ origin_parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_id: invalid
+ target_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ board_key: $__BOARD_KEY
+ status: fatal
+
+ - id: invalid_parameters_company_name
+ origin_parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_id : invalid
+ target_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ board_key: $__BOARD_KEY
+ status: fatal
+
+ push_profiles:
+ - id: valid_parameters_update_profile
+ origin_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ source_key: $__SOURCE_KEY
+ profile_key: $__PROFILE_KEY
+ target_parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_id : $__COMPANY_ID
+ company_name : $__COMPANY_NAME
+ position_id : $__POSITION_ID
+ status: success
+
+ - id: valid_parameters_send_profile
+ origin_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ source_key: $__SOURCE_KEY
+ profile_key: $__PROFILE_KEY_WITH_NO_EMAIL
+ target_parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_id : $__COMPANY_ID
+ company_name : $__COMPANY_NAME
+ position_id : $__POSITION_ID
+ status: success
+
+ - id: invalid_parameters_mail
+ origin_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ source_key: $__SOURCE_KEY
+ profile_key: $__PROFILE_KEY
+ target_parameters:
+ email: invalid
+ password: $__PASSWORD
+ company_id : $__COMPANY_ID
+ company_name : $__COMPANY_NAME
+ position_id : $__POSITION_ID
+ status: fatal
+
+ - id: missing_parameters_mail
+ origin_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ source_key: $__SOURCE_KEY
+ profile_key: $__PROFILE_KEY
+ target_parameters:
+ password: $__PASSWORD
+ company_id : $__COMPANY_ID
+ company_name : $__COMPANY_NAME
+ position_id : $__POSITION_ID
+ status: fatal
+
+ - id: invalid_parameters_password
+ origin_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ source_key: $__SOURCE_KEY
+ profile_key: $__PROFILE_KEY
+ target_parameters:
+ email: $__EMAIL
+ password: invalid
+ company_id : $__COMPANY_ID
+ company_name : $__COMPANY_NAME
+ position_id : $__POSITION_ID
+ status: fatal
+
+
+ - id: missing_parameters_password
+ origin_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ source_key: $__SOURCE_KEY
+ profile_key: $__PROFILE_KEY
+ target_parameters:
+ email: $__EMAIL
+ company_id : $__COMPANY_ID
+ company_name : $__COMPANY_NAME
+ position_id : $__POSITION_ID
+ status: fatal
+
+ - id: invalid_parameters_position_id
+ origin_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ source_key: $__SOURCE_KEY
+ profile_key: $__PROFILE_KEY
+ target_parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_id : $__COMPANY_ID
+ company_name : $__COMPANY_NAME
+ position_id : invalid
+ status: fatal
+
+ - id: missing_parameters_position_id
+ origin_parameters:
+ api_secret: $__API_SECRET
+ api_user: $__API_USER
+ source_key: $__SOURCE_KEY
+ profile_key: $__PROFILE_KEY
+ target_parameters:
+ email: $__EMAIL
+ password: $__PASSWORD
+ company_id : $__COMPANY_ID
+ company_name : $__COMPANY_NAME
+ status: fatal
\ No newline at end of file
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/utils.py b/src/hrflow_connectors/v2/connectors/breezyhr/utils.py
new file mode 100644
index 000000000..b5c9b0fb0
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/utils.py
@@ -0,0 +1,17 @@
+import re
+
+
+def remove_html_tags(text: str) -> str:
+ """
+ Remove all HTML tags in a string
+ Args:
+ text (str): text to clean
+ Returns:
+ str: cleaned text (without HTML tags)
+ """
+ return re.sub("<[^<]+?>", "", text)
+
+
+def is_valid_url(url):
+ regex = r"^https?:\/\/(www\.)?[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\/?[^\s]*$"
+ return re.match(regex, url) is not None
diff --git a/src/hrflow_connectors/v2/connectors/breezyhr/warehouse.py b/src/hrflow_connectors/v2/connectors/breezyhr/warehouse.py
new file mode 100644
index 000000000..3c054ed7e
--- /dev/null
+++ b/src/hrflow_connectors/v2/connectors/breezyhr/warehouse.py
@@ -0,0 +1,14 @@
+from hrflow_connectors.v2.connectors.breezyhr.aisles import (
+ AuthParameters,
+ JobsAisle,
+ ProfilesAisle,
+)
+from hrflow_connectors.v2.core.warehouse import Warehouse
+
+BreezyHrWarehouse = Warehouse(
+ auth=AuthParameters,
+ aisles=(
+ JobsAisle,
+ ProfilesAisle,
+ ),
+)