Repository: Riminder/hrflow-connectors (language: python)

Issue:
Feature: indeed connector to pull job offers into HrFlow boards..
This merge request will add in the hrflow_connectors boards a new connector for Indeed job postings:

- **actions.py** which contains the scripts to run to get a custom choice of  job offers from Indeed.
- The file contains one class, the **"IndeedFeed"** class, that inherits **"BoardAction"**classe , which is divided into a  **"Crawler"** function to run the chromium driver which is mandatory to **scrap** and **parse** jobs with **selenium**, a **"pull"** function that run through all the indeed pages customized by the user and scrap each job card and each job link and a **"format"** function that formats parsed attributes of job offers into the HrFlow job object format.

-It will also add inside the tests folder, a file **test_get_all_jobs_indeed.py**  to test the connector.


Discussion / hints:

Fix patch:
diff --git a/src/hrflow_connectors/__init__.py b/src/hrflow_connectors/__init__.py
index 8c3a7a724..1da36f835 100644
--- a/src/hrflow_connectors/__init__.py
+++ b/src/hrflow_connectors/__init__.py
@@ -1,3 +1,4 @@
+from hrflow_connectors.connectors.bullhorn import Bullhorn
 from hrflow_connectors.connectors.sapsuccessfactors import SAPSuccessFactors
 from hrflow_connectors.connectors.breezyhr import BreezyHR
 from hrflow_connectors.connectors.adzuna.connector import Adzuna
@@ -11,7 +12,7 @@
 from hrflow_connectors.core.documentation import generate_docs  # noqa
 
 __version__ = "2.0.0"
-__CONNECTORS__ = [SmartRecruiters, TalentSoft, PoleEmploi, Adzuna, Recruitee, Workable, BreezyHR, SAPSuccessFactors]
+__CONNECTORS__ = [SmartRecruiters, TalentSoft, PoleEmploi, Adzuna, Recruitee, Workable, BreezyHR, SAPSuccessFactors, Bullhorn]
 
 # This makes sure that connector are in module namespace
 # and that the automatic workflow code generation should work
diff --git a/src/hrflow_connectors/connectors/bullhorn/DOCUMENTATION.md b/src/hrflow_connectors/connectors/bullhorn/DOCUMENTATION.md
new file mode 100644
index 000000000..e3737be56
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/DOCUMENTATION.md
@@ -0,0 +1,42 @@
+
+# Bullhorn Connector
+
+The Bullhorn-Hrflow connector allows you to synchronize data between the Bullhorn applicant tracking system (ATS) and the Hrflow platform.
+
+  # About Bullhorn:
+  
+  **Bullhorn** is a cloud computing company headquartered in Boston, Massachusetts. The company provides customer relationship management (CRM), applicant tracking system (ATS) and operations software for the staffing industry. As of 2019, the company reported more than 11,000 customers in more than 150 countries. Besides its Boston headquarters, the company has operations in St. Louis, London, Brighton, Sydney and Rotterdam.
+
+# Connector Features
+
+**Push Profiles**
+
+This action pushes a list of Hrflow profiles to Bullhorn. The profiles are first mapped to the correct format, and then pushed to Bullhorn in four steps:
+
+1.  The profile itself is pushed to Bullhorn via the /entity/Candidate endpoint.
+    
+2.  The candidate's work history is enriched and added to the profile via the /entity/CandidateWorkHistory endpoint.
+    
+3.  The candidate's education is added to the profile via the /entity/CandidateEducation endpoint.
+    
+4.  Any attachments associated with the candidate, such as a CV or cover letter, are added to the profile via the /entity/CandidateFileAttachment endpoint.
+    
+
+**Read Jobs**
+
+This action pulls job listings from Bullhorn via the /search/JobOrder endpoint and pushes them to an Hrflow job board. If there are more than 20 jobs in Bullhorn, additional Get requests will be made, changing the "start" parameter as needed.
+
+**Pull Profiles**
+
+This action consists of two separate actions:
+
+1.  **The read_profiles** action pulls profiles from Bullhorn via the /entity/Candidate endpoint and pushes them to an Hrflow job board.
+    
+2.  The **read_profiles_parsing** action pulls a candidate's CV and uses Hrflow's parsing AI to enrich the previously pulled profile with information found on the CV.
+
+
+# Useful links:
+
+📄Visit [Bullhorn]([https://www.bullhorn.com/]) to learn more.
+
+💻 [Connector code]([https://github.com/Sprenger07/hrflow-connectors/tree/feature/update-connector-workable/src/hrflow_connectors/connectors/workable](https://github.com/Sprenger07/hrflow-connectors/tree/feature/update-connector-workable/src/hrflow_connectors/connectors/workable)) on our Github.
diff --git a/src/hrflow_connectors/connectors/bullhorn/README.md b/src/hrflow_connectors/connectors/bullhorn/README.md
new file mode 100644
index 000000000..6715983f9
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/README.md
@@ -0,0 +1,14 @@
+
+# Bullhorn Connector
+
+> Transform Your Business with Bullhorn Staffing and Recruitment Software
+
+
+🔗 https://www.bullhorn.com/
+
+| Actions |
+| ------- |
+| [**Push profile**](docs/push_profile.md) |
+| [**Pull job**](docs/pull_job.md) |
+| [**Pull profile parsing**](docs/pull_profile_parsing.md) |
+| [**Pull profile**](docs/pull_profile.md) |
diff --git a/src/hrflow_connectors/connectors/bullhorn/__init__.py b/src/hrflow_connectors/connectors/bullhorn/__init__.py
new file mode 100644
index 000000000..5e4c4684c
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/__init__.py
@@ -0,0 +1,1 @@
+from hrflow_connectors.connectors.bullhorn.connector import Bullhorn
\ No newline at end of file
diff --git a/src/hrflow_connectors/connectors/bullhorn/bullhorn_iFrame.md b/src/hrflow_connectors/connectors/bullhorn/bullhorn_iFrame.md
new file mode 100644
index 000000000..99217de15
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/bullhorn_iFrame.md
@@ -0,0 +1,25 @@
+# Building an iFrame on Bullhorn App
+
+## Creating a View Layout:
+
+![image](https://user-images.githubusercontent.com/46778695/206200113-db710961-0597-41a7-afe4-d4970770e5d3.png)
+
+In order to access the View Layout menu, you need to access Menu->Admin->View Layout
+
+
+## Chosing the Private Label and Field Map Entity:
+![image](https://user-images.githubusercontent.com/46778695/206200537-81f52c45-8806-4d9b-8af4-593231f2432c.png)
+
+Choose your label and Field Map Entity. It can be a Job, a Candidate or a Placement. In our case it is a Job.
+⚠️ If your entity is Job, don't forget to do all the steps again for Job II, Job III and Job IV.
+
+## Setting up the iFrame:
+![image](https://user-images.githubusercontent.com/46778695/206200796-3b9b990b-e5b5-454e-ac1a-a9758180cd62.png)
+
+There is three ways to set up an iFrame on the Bullhorn app. It can be a Custom Tab, Custom Menu Action or a Custom List Button. In our case it is a custom tab called Matching that will appear on the Job submenu.
+Chose name, click the enable box, enter the url and chose your user types !
+
+## Using the iFrame:
+![image](https://user-images.githubusercontent.com/46778695/206201147-ae04c375-994f-410e-927d-d6aae991050f.png)
+
+You now have a Tab called Matching on the Job submenu. Click on it and the iFrame will run.
diff --git a/src/hrflow_connectors/connectors/bullhorn/connector.py b/src/hrflow_connectors/connectors/bullhorn/connector.py
new file mode 100644
index 000000000..1db1e2fd3
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/connector.py
@@ -0,0 +1,419 @@
+import base64
+import typing as t
+
+import requests
+
+from hrflow_connectors.connectors.bullhorn.schemas import BullhornProfile
+from hrflow_connectors.connectors.bullhorn.utils import date_format
+from hrflow_connectors.connectors.bullhorn.warehouse import (
+    BullhornJobWarehouse,
+    BullhornProfileParsingWarehouse,
+    BullhornProfileWarehouse,
+)
+from hrflow_connectors.connectors.hrflow.schemas import HrFlowProfile
+from hrflow_connectors.connectors.hrflow.warehouse.job import HrFlowJobWarehouse
+from hrflow_connectors.connectors.hrflow.warehouse.profile import (
+    HrFlowProfileParsingWarehouse,
+    HrFlowProfileWarehouse,
+)
+from hrflow_connectors.core import (
+    BaseActionParameters,
+    Connector,
+    ConnectorAction,
+    WorkflowType,
+)
+
+
+def to_int(elm: t.Any) -> int:
+    if elm is None:
+        return 0
+    return int(elm)
+
+
+def get_location(info: t.Dict) -> t.Dict:
+    if info is not None:
+        location = info.get("location")
+        if location is None:
+            location = dict()
+        fields = location.get("fields", {})
+        if fields == []:
+            fields = {}
+        location_dict = {
+            "address1": location.get("text"),
+            "city": fields.get("city"),
+            "state": fields.get("country"),
+            "zip": fields.get("postcode"),
+        }
+        return location_dict
+    return None
+
+
+def get_skills(data: t.Dict) -> str:
+    skills = ""
+    if data.get("skills") is not None:
+        for i in range(len(data["skills"]) - 1):
+            skills += data["skills"][i]["name"] + ", "
+        skills += data["skills"][-1]["name"]
+    return skills
+
+
+def get_education(education_list: t.List[t.Dict]) -> t.List[t.Dict]:
+    educations = []
+    for hrflow_education in education_list:
+        location = hrflow_education["location"]
+        education = {
+            "id": "0",
+            "candidate": {"id": None},
+            "school": hrflow_education.get("school"),
+            "degree": hrflow_education.get("title"),
+            "comments": hrflow_education.get("description"),
+            "city": location.get("text") if location else None,
+            "startDate": int(
+                date_format.from_str_to_datetime(
+                    hrflow_education.get("date_start")
+                ).timestamp()
+            )
+            if hrflow_education.get("date_start")
+            else None,
+            "endDate": int(
+                date_format.from_str_to_datetime(
+                    hrflow_education.get("date_end")
+                ).timestamp()
+            )
+            if hrflow_education.get("date_start")
+            else None,
+        }
+        educations.append(education)
+    return educations
+
+
+def get_experience(experience_list: t.List[t.Dict]) -> t.List[t.Dict]:
+    experience_json = []
+    for hrflow_experience in experience_list:
+        experience = {
+            "id": "0",
+            "candidate": {"id": None},
+            "companyName": hrflow_experience.get("company"),
+            "title": hrflow_experience.get("title"),
+            "comments": hrflow_experience.get("description"),
+            "startDate": int(
+                date_format.from_str_to_datetime(
+                    hrflow_experience.get("date_start")
+                ).timestamp()
+            )
+            if hrflow_experience.get("date_start")
+            else None,
+            "endDate": int(
+                date_format.from_str_to_datetime(
+                    hrflow_experience.get("date_end")
+                ).timestamp()
+            )
+            if hrflow_experience.get("date_end")
+            else None,
+        }
+        experience_json.append(experience)
+    return experience_json
+
+
+def get_attachments(attachment_list: t.List[t.Dict]) -> t.List[t.Dict]:
+    attachments_json = []
+    for hrflow_attachment in attachment_list:
+        url = hrflow_attachment["public_url"]
+        response = requests.get(url)
+        b64 = base64.b64encode(response.content)
+
+        attachment = {
+            "externalID": "portfolio",
+            "fileContent": b64.decode(),
+            "fileType": "SAMPLE",
+            "name": hrflow_attachment["file_name"],
+            "contentType": "text/plain",
+            "description": "Resume file for candidate.",
+            "type": "cover",
+        }
+        attachments_json.append(attachment)
+    return attachments_json
+
+
+def format_profile(data: HrFlowProfile) -> t.Dict:
+    info = data.get("info")
+
+    date_of_birth = None
+    if info is not None and info.get("date_birth"):
+        date_birth_field = info.get("date_birth")
+        date_birth_timestamp = date_format.from_str_to_datetime(
+            date_birth_field
+        ).timestamp()
+        date_of_birth = int(date_birth_timestamp)
+
+    create_profile_body = {
+        "id": data.get("reference"),
+        "address": get_location(info),
+        "certifications": None,
+        "name": info.get("full_name") if info else None,
+        "firstName": info.get("first_name") if info else None,
+        "lastName": info.get("last_name") if info else None,
+        "email": info.get("email") if info else None,
+        "mobile": info.get("phone") if info else None,
+        "dateOfBirth": date_of_birth,
+        "experience": to_int(data.get("experiences_duration")),  # TODO
+        "skillSet": get_skills(data) if data.get("skills") else None,
+    }
+
+    enrich_profile_education = get_education(data.get("educations"))
+    enrich_profile_experience = get_experience(data.get("experiences"))
+    enrich_profile_attachment = get_attachments(data.get("attachments"))
+    # Four querys are needed to index a Candidate to Bullhorn
+    # The querys's body are grouped in a profile_body_dict
+    profile_body_dict = dict(
+        create_profile_body=create_profile_body,
+        enrich_profile_education=enrich_profile_education,
+        enrich_profile_experience=enrich_profile_experience,
+        enrich_profile_attachment=enrich_profile_attachment,
+    )
+    return profile_body_dict
+
+
+def format_job(data: t.Dict) -> t.Dict:
+
+    # Info
+    hrflow_name = data.get("title")
+    hrflow_ref = str(data.get("id"))
+
+    # Location
+    address = data.get("address")
+    hrflow_fields = {
+        "city": address["city"],
+        "country": address["countryCode"],
+        "postal_code": address["zip"],
+    }
+    hrflow_location = {"text": address["address1"], "fields": hrflow_fields}
+
+    # Sections
+    section_description = {
+        "name": "Bullhorn_description",
+        "title": "Bullhorn_description",
+        "description": data["publicDescription"],
+    }
+    hrlflow_sections = [section_description]
+
+    # Tags
+    degree_list = data.get("degreeList")
+    if degree_list:
+        degree_list = ", ".join(degree_list)
+
+    tags = []
+    tags.append({"name": "durationWeeks", "value": data.get("durationWeeks")})
+    tags.append({"name": "degreeList", "value": degree_list})
+    tags.append({"name": "employmentType", "value": data.get("employmentType")})
+    tags.append({"name": "numOpenings", "value": data.get("numOpenings")})
+    tags.append({"name": "onSite", "value": data.get("onSite")})
+    tags.append({"name": "salaryUnit", "value": data.get("salaryUnit")})
+    tags.append({"name": "startDate", "value": data.get("startDate")})
+    tags.append({"name": "status", "value": data.get("status")})
+    tags.append({"name": "type", "value": data.get("type")})
+    tags.append({"name": "willRelocate", "value": data.get("willRelocate")})
+    tags.append({"name": "salary", "value": data.get("salary")})
+    tags.append({"name": "isWorkFromHome", "value": data.get("isWorkFromHome")})
+    tags.append({"name": "hoursPerWeek", "value": data.get("hoursPerWeek")})
+    tags.append({"name": "hoursOfOperation", "value": data.get("hoursOfOperation")})
+    tags.append({"name": "dateAdded", "value": data.get("dateAdded")})
+
+    # Skills
+    hrflow_skills = []
+    skill_list = data["skillList"]
+    if skill_list:
+        skill_list = skill_list.split(",")
+        if skill_list:
+            for skill in skill_list:
+                new_skill = {"name": skill, "type": "undefined", "value": None}
+                hrflow_skills.append(new_skill)
+
+    hrflow_job = {
+        "name": hrflow_name,
+        "reference": hrflow_ref,
+        "location": hrflow_location,
+        "sections": hrlflow_sections,
+        "skills": hrflow_skills,
+        "tags": tags,
+    }
+
+    return hrflow_job
+
+
+def profile_format_parsing(data: BullhornProfile) -> t.Dict:
+
+    profile = {}
+
+    # Tags
+    tags = []
+    tags.append({"name": "dateAvailable", "value": data.get("dateAvailable")})
+    tags.append({"name": "status", "value": data.get("status")})
+    tags.append({"name": "employeeType", "value": data.get("employeeType")})
+    tags.append(
+        {"name": "activePlacements", "value": data.get("activePlacements").get("total")}
+    )
+
+    profile["reference"] = str(data.get("id"))
+    profile["tags"] = tags
+    profile["metadatas"] = []
+    profile["created_at"] = None
+
+    profile["resume"] = {"raw": data["cvFile"], "content_type": "application/pdf"}
+
+    return profile
+
+
+def profile_format(data: BullhornProfile) -> t.Dict:
+    # Info
+    first_name = data["firstName"]
+    last_name = data["lastName"]
+    full_name = data["name"]
+    email = data["email"]
+    phone = data["mobile"]
+    date_birth = data["dateOfBirth"]
+    gender = data["gender"]
+
+    # Location
+    location_text = data["address"]["address1"]
+    location = {"text": location_text}
+
+    info = {
+        "full_name": full_name,
+        "first_name": first_name,
+        "last_name": last_name,
+        "email": email,
+        "phone": phone,
+        "date_birth": date_birth,
+        "location": location,
+        "gender": gender,
+    }
+
+    # Tags
+    tags = []
+    tags.append({"name": "dateAvailable", "value": data.get("dateAvailable")})
+    tags.append({"name": "status", "value": data.get("status")})
+    tags.append({"name": "employeeType", "value": data.get("employeeType")})
+    tags.append(
+        {"name": "activePlacements", "value": data.get("activePlacements").get("total")}
+    )
+
+    # Skills
+    hrflow_skills = []
+    skill_list = data["skillSet"]
+    if skill_list:
+        skill_list = skill_list.split(",")
+        if skill_list:
+            for skill in skill_list:
+                new_skill = {"name": skill, "type": "hard", "value": None}
+                hrflow_skills.append(new_skill)
+
+    # Education
+    hrflow_education = []
+    for education in data["educations"]:
+        location = {"text": education["city"], "lng": None, "lat": None}
+        school = education["school"]
+        date_start = education["startDate"]
+        date_end = education["endDate"]
+        title = education["degree"]
+        certifications = [education["certification"]]
+        description = education["comments"]
+        object_education = {
+            "location": location,
+            "school": school,
+            "date_start": date_start,
+            "date_end": date_end,
+            "title": title,
+            "certifications": certifications,
+            "description": description,
+        }
+        hrflow_education.append(object_education)
+
+    # Experience
+    hrflow_experience = []
+    for experience in data["workHistories"]:
+        location = {"text": "", "lng": None, "lat": None}
+        company = experience["companyName"]
+        date_start = experience["startDate"]
+        date_end = experience["endDate"]
+        title = experience["title"]
+        description = experience["comments"]
+        title = experience["title"]
+        object_experience = {
+            "title": title,
+            "location": location,
+            "company": company,
+            "date_start": date_start,
+            "date_end": date_end,
+            "title": title,
+            "description": description,
+        }
+        hrflow_experience.append(object_experience)
+
+    profile = {
+        "info": info,
+        "skills": hrflow_skills,
+        "experiences": hrflow_experience,
+        "tags": tags,
+        "educations": hrflow_education,
+    }
+    profile["reference"] = str(data.get("id"))
+
+    return profile
+
+
+DESCRIPTION = "Transform Your Business with Bullhorn Staffing and Recruitment Software"
+
+Bullhorn = Connector(
+    name="Bullhorn",
+    description=DESCRIPTION,
+    url="https://www.bullhorn.com/",
+    actions=[
+        ConnectorAction(
+            name="push_profile",
+            trigger_type=WorkflowType.catch,
+            description=(
+                "Writes a profile from Hrflow.ai Source to Bullhorn via the API"
+            ),
+            parameters=BaseActionParameters.with_defaults(
+                "WriteProfileActionParameters", format=format_profile
+            ),
+            origin=HrFlowProfileWarehouse,
+            target=BullhornProfileWarehouse,
+        ),
+        ConnectorAction(
+            name="pull_job",
+            trigger_type=WorkflowType.pull,
+            description="Writes a job to Hrflow.ai Board from Bullhorn via the API",
+            parameters=BaseActionParameters.with_defaults(
+                "ReadJobsActionParameters", format=format_job
+            ),
+            origin=BullhornJobWarehouse,
+            target=HrFlowJobWarehouse,
+        ),
+        ConnectorAction(
+            name="pull_profile_parsing",
+            trigger_type=WorkflowType.pull,
+            description=(
+                "Parses a CV profile to Hrflow.ai source from Bullhorn via the API"
+            ),
+            parameters=BaseActionParameters.with_defaults(
+                "ReadProfileActionParameters", format=profile_format_parsing
+            ),
+            origin=BullhornProfileParsingWarehouse,
+            target=HrFlowProfileParsingWarehouse,
+        ),
+        ConnectorAction(
+            name="pull_profile",
+            trigger_type=WorkflowType.pull,
+            description=(
+                "Writes a profile to Hrflow.ai source from Bullhorn via the API"
+            ),
+            parameters=BaseActionParameters.with_defaults(
+                "ReadProfileActionParameters", format=profile_format
+            ),
+            origin=BullhornProfileWarehouse,
+            target=HrFlowProfileWarehouse,
+        ),
+    ],
+)
diff --git a/src/hrflow_connectors/connectors/bullhorn/docs/pull_job.md b/src/hrflow_connectors/connectors/bullhorn/docs/pull_job.md
new file mode 100644
index 000000000..d14730e3d
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/docs/pull_job.md
@@ -0,0 +1,72 @@
+
+# Pull job
+`Bullhorn Jobs` :arrow_right: `HrFlow.ai Jobs`
+
+Writes a job to Hrflow.ai Board from Bullhorn via the API
+
+
+
+## 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_job`](../connector.py#L177) | 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 |
+| ----- | ---- | ------- | ----------- |
+| `client_id` :red_circle: | `str` | None | Client identifier for Bullhorn |
+| `client_secret` :red_circle: | `str` | None | Client secret identifier for Bullhorn |
+| `password` :red_circle: | `str` | None | Passoword for Bullhorn login |
+| `username` :red_circle: | `str` | None | Username for Bullhorn login |
+
+## 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 Bullhorn
+from hrflow_connectors.core import ReadMode
+
+
+logging.basicConfig(level=logging.INFO)
+
+
+Bullhorn.pull_job(
+    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(
+        client_id="your_client_id",
+        client_secret="your_client_secret",
+        password="your_password",
+        username="your_username",
+    ),
+    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/connectors/bullhorn/docs/pull_profile.md b/src/hrflow_connectors/connectors/bullhorn/docs/pull_profile.md
new file mode 100644
index 000000000..4772ce56a
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/docs/pull_profile.md
@@ -0,0 +1,70 @@
+
+# Pull profile
+`Bullhorn Profiles` :arrow_right: `HrFlow.ai Profiles`
+
+Writes a profile to Hrflow.ai source from Bullhorn via the API
+
+
+
+## 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]` | [`profile_format`](../connector.py#L267) | 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 |
+| ----- | ---- | ------- | ----------- |
+| `client_id` :red_circle: | `str` | None | Client identifier for Bullhorn |
+| `client_secret` :red_circle: | `str` | None | Client secret identifier for Bullhorn |
+| `password` :red_circle: | `str` | None | Passoword for Bullhorn login |
+| `username` :red_circle: | `str` | None | Username for Bullhorn login |
+
+## 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: | `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 Bullhorn
+from hrflow_connectors.core import ReadMode
+
+
+logging.basicConfig(level=logging.INFO)
+
+
+Bullhorn.pull_profile(
+    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(
+        client_id="your_client_id",
+        client_secret="your_client_secret",
+        password="your_password",
+        username="your_username",
+    ),
+    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/connectors/bullhorn/docs/pull_profile_parsing.md b/src/hrflow_connectors/connectors/bullhorn/docs/pull_profile_parsing.md
new file mode 100644
index 000000000..3a7ca4060
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/docs/pull_profile_parsing.md
@@ -0,0 +1,68 @@
+
+# Pull profile parsing
+`Bullhorn Profiles` :arrow_right: `HrFlow.ai Profile Parsing`
+
+Parses a CV profile to Hrflow.ai source from Bullhorn via the API
+
+
+
+## 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]` | [`profile_format_parsing`](../connector.py#L244) | 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 |
+| ----- | ---- | ------- | ----------- |
+| `client_id` :red_circle: | `str` | None | Client identifier for Bullhorn |
+| `client_secret` :red_circle: | `str` | None | Client secret identifier for Bullhorn |
+| `password` :red_circle: | `str` | None | Passoword for Bullhorn login |
+| `username` :red_circle: | `str` | None | Username for Bullhorn login |
+
+## 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 |
+| `only_insert`  | `bool` | False | When enabled the profile is written only if it doesn't exist in the source |
+
+:red_circle: : *required*
+
+## Example
+
+```python
+import logging
+from hrflow_connectors import Bullhorn
+from hrflow_connectors.core import ReadMode
+
+
+logging.basicConfig(level=logging.INFO)
+
+
+Bullhorn.pull_profile_parsing(
+    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(
+        client_id="your_client_id",
+        client_secret="your_client_secret",
+        password="your_password",
+        username="your_username",
+    ),
+    target_parameters=dict(
+        api_secret="your_api_secret",
+        api_user="your_api_user",
+        source_key="your_source_key",
+        only_insert=False,
+    )
+)
+```
\ No newline at end of file
diff --git a/src/hrflow_connectors/connectors/bullhorn/docs/push_profile.md b/src/hrflow_connectors/connectors/bullhorn/docs/push_profile.md
new file mode 100644
index 000000000..485298263
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/docs/push_profile.md
@@ -0,0 +1,68 @@
+
+# Push profile
+`HrFlow.ai Profiles` :arrow_right: `Bullhorn Profiles`
+
+Writes a profile from Hrflow.ai Source to Bullhorn via the API
+
+
+
+## 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#L138) | 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 |
+| ----- | ---- | ------- | ----------- |
+| `client_id` :red_circle: | `str` | None | Client identifier for Bullhorn |
+| `client_secret` :red_circle: | `str` | None | Client secret identifier for Bullhorn |
+| `password` :red_circle: | `str` | None | Passoword for Bullhorn login |
+| `username` :red_circle: | `str` | None | Username for Bullhorn login |
+
+:red_circle: : *required*
+
+## Example
+
+```python
+import logging
+from hrflow_connectors import Bullhorn
+from hrflow_connectors.core import ReadMode
+
+
+logging.basicConfig(level=logging.INFO)
+
+
+Bullhorn.push_profile(
+    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(
+        client_id="your_client_id",
+        client_secret="your_client_secret",
+        password="your_password",
+        username="your_username",
+    )
+)
+```
\ No newline at end of file
diff --git a/src/hrflow_connectors/connectors/bullhorn/schemas.py b/src/hrflow_connectors/connectors/bullhorn/schemas.py
new file mode 100644
index 000000000..65267fd60
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/schemas.py
@@ -0,0 +1,133 @@
+from typing import Any, Optional
+
+from pydantic import BaseModel, Field
+
+
+class BullhornAddress(BaseModel):
+    address1: Optional[str] = Field(None, Description="Adress of the profile")
+    city: Optional[str] = Field(None, Description="City of the profile")
+    state: Optional[str] = Field(None, Description="Country code of the profile")
+    zip: Optional[str] = Field(None, Description="Postal code of the profile")
+
+
+class BullhornProfile(BaseModel):
+    id: Optional[str] = Field(None, Description="Unique identifier for this entity")
+    address: Optional[BullhornAddress] = Field(None, Description="Candidate address")
+    certifications: Any = Field(None, Description="Candidate’s certifications")
+    name: Optional[str] = Field(
+        None,
+        Description=(
+            "Candidate’s full name. If setting firstname or lastname, you must also set"
+            " this field; it does not populate automatically"
+        ),
+    )
+    firstName: Optional[str] = Field(None, Description="Candidate’s first name")
+    lastName: Optional[str] = Field(None, Description="Name of the file")
+    email: Optional[Optional[str]] = Field(
+        None, Description="Candidate’s email address"
+    )
+    mobile: Optional[Optional[str]] = Field(
+        None, Description="Candidate’s mobile (cell) telephone number"
+    )
+    dateOfBirth: Optional[int] = Field(None, Description="Candidate’s date of birth")
+    experience: Optional[int] = Field(
+        None, Description="Number of years of experience that the Candidate has"
+    )
+    skillSet: Optional[str] = Field(
+        None, Description="Text description of Candidate’s skills"
+    )
+
+
+class BullhornAttachmentEnrichment(BaseModel):
+    externalID: Optional[str] = Field(
+        None, Description="External identifier for the file"
+    )
+    fileContent: Optional[str] = Field(
+        None, Description="Base64-encoded Optional[str]ing of the file content"
+    )
+    fileExtension: Optional[Optional[str]] = Field(
+        None, Description="Extension of the file. For example, .doc or .jpg"
+    )
+    fileType: Optional[str] = Field(None, Description="Always use the value “SAMPLE”")
+    name: Optional[str] = Field(
+        None,
+        Description=(
+            "File name. If a file extension is included as part of the name and the "
+            "fileExtension field is not set, the file extension in the name is used."
+        ),
+    )
+    contentType: Optional[str] = Field(
+        None, Description="Type/subtype of the file content.type"
+    )
+    description: Optional[str] = Field(
+        None, Description="Unique identifier for this entity"
+    )
+    type: Optional[str]
+
+
+class BullhornCandidate(BaseModel):
+    id: Optional[Optional[int]] = Field(
+        None, Description="Unique identifier for this entity"
+    )
+
+
+class BullhornExperienceEnrichment(BaseModel):
+    id: Optional[str] = Field(None, Description="Unique identifier for this entity")
+    candidate: BullhornCandidate = Field(
+        None, Description="Candidate for whom this person is a reference"
+    )
+    companyName: Optional[Optional[str]] = Field(
+        None,
+        Description=(
+            "Name of the company where reference works, if it does not "
+            "have a ClientCorporation record in Bullhorn"
+        ),
+    )
+    title: Optional[Optional[str]] = Field(
+        None, Description="Candidate’s job title in this position"
+    )
+    comments: Optional[Optional[str]] = Field(
+        None, Description="Free-text comments on CandidateWorkHistory"
+    )
+    startDate: Optional[Optional[int]] = Field(
+        None, Description="Date on which Candidate began working at this position"
+    )
+    endDate: Optional[Optional[int]] = Field(
+        None, Description="Date on which job ended, if applicable"
+    )
+
+
+class BullhornEducationEnrichment(BaseModel):
+    id: Optional[str] = Field(None, Description="Unique identifier for this entity")
+    candidate: BullhornCandidate = Field(
+        None, Description="Candidate for whom this person is a reference"
+    )
+    school: Optional[Optional[str]] = Field(
+        None,
+        Description="Name of the educational institute where this education took place",
+    )
+    degree: Optional[Optional[str]] = Field(
+        None,
+        Description=(
+            "Indicates what educational degree the Candidate received; for "
+            "example, B.A., M.A., Ph.D., and so forth"
+        ),
+    )
+    comments: Optional[Optional[str]] = Field(
+        None, Description="Free-text comments on this record"
+    )
+    city: Optional[Optional[str]] = Field(
+        None, Description="Name of the city where the education took place"
+    )
+    startDate: Optional[Optional[int]] = Field(
+        None, Description="Date when Candidate began study"
+    )
+    endDate: Optional[Optional[int]] = Field(
+        None, Description="Date when Candidate finished this education"
+    )
+
+
+class BullhornJob(BaseModel):
+    id: Optional[Optional[int]] = Field(
+        None, Description="Unique identifier for this entity"
+    )
diff --git a/src/hrflow_connectors/connectors/bullhorn/utils/authentication.py b/src/hrflow_connectors/connectors/bullhorn/utils/authentication.py
new file mode 100644
index 000000000..a9eda43de
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/utils/authentication.py
@@ -0,0 +1,75 @@
+import json
+import urllib.parse as urlparse
+import urllib.request as urllib2
+
+import requests
+
+base_url = "https://auth.bullhornstaffing.com/oauth"
+
+
+class AuthCodeRedirectHandler(urllib2.HTTPRedirectHandler):
+    """
+    A bare bones redirect handler that pulls the auth code sent back
+    by OAuth off the query string of the redirect URI given in the
+    Location header.  Does no checking for other errors or bad/missing
+    information.
+    """
+
+    def http_error_302(self, req, fp, code, msg, headers):
+        """handler for 302 responses that assumes a properly constructed
+        OAuth 302 response and pulls the auth code out of the header"""
+        qs = urlparse.urlparse(headers["Location"]).query
+        auth_code = urlparse.parse_qs(qs)["code"][0]
+        return auth_code
+
+
+def build_auth_code_request(username, password, client_id):
+    data = {
+        "client_id": client_id,
+        "response_type": "code",
+        "username": username,
+        "password": password,
+        "action": "Login",
+    }
+
+    encoded = urlparse.urlencode(data)
+    encoded = encoded.encode("utf-8")
+
+    req = urllib2.Request(url=base_url + "/authorize", data=encoded)
+    return req
+
+
+def get_access_token(code, client_id, client_secret):
+    """
+    Gets an OAuth access token given an OAuth authorization code
+    """
+    data = {
+        "grant_type": "authorization_code",
+        "client_id": client_id,
+        "client_secret": client_secret,
+        "code": code,
+    }
+    encoded = urlparse.urlencode(data)
+    encoded = encoded.encode("utf-8")
+
+    req = urllib2.Request(base_url + "/token", encoded)
+    return urllib2.urlopen(req).read()
+
+
+def auth(username, password, client_id, client_secret):
+    req = build_auth_code_request(username, password, client_id)
+    opener = urllib2.build_opener(AuthCodeRedirectHandler)
+
+    auth_code = opener.open(req)
+    access_token = get_access_token(auth_code, client_id, client_secret)
+    access_token = access_token.decode("utf-8")
+    access_token = json.loads(access_token)
+
+    login_url = "https://rest.bullhornstaffing.com/rest-services/login"
+    params = {"version": "2.0", "access_token": access_token["access_token"]}
+
+    response = requests.post(url=login_url, params=params)
+    response = response.json()
+    response["refresh_token"] = access_token["refresh_token"]
+
+    return response
diff --git a/src/hrflow_connectors/connectors/bullhorn/utils/date_format.py b/src/hrflow_connectors/connectors/bullhorn/utils/date_format.py
new file mode 100644
index 000000000..f96f0a10c
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/utils/date_format.py
@@ -0,0 +1,123 @@
+import datetime
+import re
+from typing import Union
+
+
+class ParseError(ValueError):
+    """
+    Parse Error
+    """
+
+    def __init__(self, message):
+        super().__init__(message)
+
+
+class DateFormatError(ParseError):
+    """
+    Date Format Error
+    """
+
+    def __init__(self, value):
+        super().__init__(
+            "Le format de la date ne respecte pas la norme ISO8601 : `{}`".format(value)
+        )
+
+
+def any_to_int(any: Union[None, str]) -> int:
+    """
+    Convert int string to integer. if this value is `None`, return `0`
+    Args:
+        any (Union[None, str]): value to convert
+    Returns:
+        int: converted integer
+    """
+    if any is None:
+        return 0
+    return int(any)
+
+
+def from_str_to_datetime(datetime_str: str) -> datetime.datetime:
+    """
+    Convert string to `datetime.datetime`.
+    The date must respect ISO8601 format.
+    Args:
+        datetime_str (str): date formatted with the norme ISO8601
+    Raises:
+        DateFormatError: "Le format de la date ne respecte pas la norme ISO8601 : ..."
+    Returns:
+        datetime.datetime: converted Datetime object
+    """
+    isoformat_regex = r"""^(?P<year>\d{4})(-|/)(?P<month>\d{2})(-|/)(?P<day>\d{2})(.(?P<hour>\d{2})
+            (:(?P<minute>\d{2})(:(?P<second>\d{2})(\.(?P<millisecond>\d{1,6}))?)?)?)?(?P<tz>((?P<tz_symbol>-|\+)
+            (?P<tz_hour>\d{2}):?(?P<tz_minute>\d{2})(:(?P<tz_second>\d{2})
+            (\.(?P<tz_millisecond>\d{1,6}))?)?)|Z)?$"""
+
+    match = re.search(isoformat_regex, datetime_str)
+    if not match:
+        raise DateFormatError(datetime_str)
+
+    # Extract value from string
+    # Datetime
+    year, month, day = match.group("year", "month", "day")
+    hour, minute, second, millisecond = match.group(
+        "hour", "minute", "second", "millisecond"
+    )
+
+    # TimeZone
+    tz_field_in_str = match.group("tz")  # example : +02:00
+    tz_symbol = match.group("tz_symbol")
+    tz_hour, tz_minute, tz_second, tz_millisecond = match.group(
+        "tz_hour", "tz_minute", "tz_second", "tz_millisecond"
+    )
+
+    # Pad right with 0 the fields millisecond and tz_millisecond
+    # "123" => "123000"
+    if millisecond is not None:
+        millisecond = millisecond.ljust(6, "0")
+    if tz_millisecond is not None:
+        tz_millisecond = tz_millisecond.ljust(6, "0")
+
+    # Convert each field to int
+    try:
+        year, month, day = any_to_int(year), any_to_int(month), any_to_int(day)
+        hour, minute, second, millisecond = (
+            any_to_int(hour),
+            any_to_int(minute),
+            any_to_int(second),
+            any_to_int(millisecond),
+        )
+        tz_hour, tz_minute, tz_second, tz_millisecond = (
+            any_to_int(tz_hour),
+            any_to_int(tz_minute),
+            any_to_int(tz_second),
+            any_to_int(tz_millisecond),
+        )
+    except ValueError:
+        raise DateFormatError(datetime_str)
+
+    # Convert to Object
+    delta = datetime.timedelta(
+        hours=tz_hour, minutes=tz_minute, seconds=tz_second, microseconds=tz_millisecond
+    )
+    if tz_symbol == "-":
+        # when -HH:MM
+        delta = -1 * delta
+
+    timezone = datetime.timezone(delta)
+
+    if tz_field_in_str is None:
+        timezone = None
+
+    try:
+        return datetime.datetime(
+            year=year,
+            month=month,
+            day=day,
+            hour=hour,
+            minute=minute,
+            second=second,
+            microsecond=millisecond,
+            tzinfo=timezone,
+        )
+    except ValueError:
+        raise DateFormatError(datetime_str)
diff --git a/src/hrflow_connectors/connectors/bullhorn/warehouse.py b/src/hrflow_connectors/connectors/bullhorn/warehouse.py
new file mode 100644
index 000000000..ea42f6de0
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/warehouse.py
@@ -0,0 +1,420 @@
+import typing as t
+from logging import LoggerAdapter
+
+import requests
+from pydantic import Field
+
+from hrflow_connectors.connectors.bullhorn.schemas import BullhornJob, BullhornProfile
+from hrflow_connectors.connectors.bullhorn.utils.authentication import auth
+from hrflow_connectors.core import (
+    DataType,
+    FieldType,
+    ParametersModel,
+    Warehouse,
+    WarehouseReadAction,
+    WarehouseWriteAction,
+)
+from hrflow_connectors.core.warehouse import ReadMode
+
+
+class WriteProfilesParameters(ParametersModel):
+    client_id: str = Field(
+        ...,
+        description="Client identifier for Bullhorn",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+    client_secret: str = Field(
+        ...,
+        description="Client secret identifier for Bullhorn",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+    password: str = Field(
+        ...,
+        description="Passoword for Bullhorn login",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+    username: str = Field(
+        ...,
+        description="Username for Bullhorn login",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+
+class ReadJobsParameters(ParametersModel):
+    client_id: str = Field(
+        ...,
+        description="Client identifier for Bullhorn",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+    client_secret: str = Field(
+        ...,
+        description="Client secret identifier for Bullhorn",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+    password: str = Field(
+        ...,
+        description="Passoword for Bullhorn login",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+    username: str = Field(
+        ...,
+        description="Username for Bullhorn login",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+
+class ReadProfileParameters(ParametersModel):
+    client_id: str = Field(
+        ...,
+        description="Client identifier for Bullhorn",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+    client_secret: str = Field(
+        ...,
+        description="Client secret identifier for Bullhorn",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+    password: str = Field(
+        ...,
+        description="Passoword for Bullhorn login",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+    username: str = Field(
+        ...,
+        description="Username for Bullhorn login",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+
+def write(
+    adapter: LoggerAdapter,
+    parameters: WriteProfilesParameters,
+    profiles: t.Iterable[t.Dict],
+) -> t.List[t.Dict]:
+    adapter.info("Pushing {} profiles".format(len(profiles)))
+    failed_profiles = []
+    authentication = auth(
+        parameters.username,
+        parameters.password,
+        parameters.client_id,
+        parameters.client_secret,
+    )
+
+    for profile in profiles:
+
+        # Split profile in four parts: Body, Education, Experience and Attachements
+        profile_body_dict = profile
+        create_profile_body = profile_body_dict["create_profile_body"]
+        enrich_profile_education = profile_body_dict["enrich_profile_education"]
+        enrich_profile_experience = profile_body_dict["enrich_profile_experience"]
+        enrich_profile_attachment = profile_body_dict["enrich_profile_attachment"]
+
+        rest_url = authentication["restUrl"]
+        params = {"BhRestToken": authentication["BhRestToken"]}
+
+        candidate_url = rest_url + "entity/Candidate"
+        response = requests.put(
+            url=candidate_url, json=create_profile_body, params=params
+        )
+
+        # Unable to push profile
+        if response.status_code // 100 != 2:
+            adapter.error(
+                "Failed to push profile from to Bullhorn"
+                " status_code={} response={}".format(
+                    response.status_code, response.text
+                )
+            )
+            failed_profiles.append(profile)
+            continue
+
+        candidate_id = response.json()
+        candidate_id = str(candidate_id["changedEntityId"])
+
+        # Enrich profile education
+        for education in enrich_profile_education:
+            education["candidate"]["id"] = candidate_id
+            education_url = rest_url + "entity/CandidateEducation"
+            response = requests.put(url=education_url, json=education, params=params)
+
+        # Enrich profile experience
+        for experience in enrich_profile_experience:
+            experience["candidate"]["id"] = candidate_id
+            experience_url = rest_url + "entity/CandidateWorkHistory"
+            response = requests.put(url=experience_url, json=experience, params=params)
+
+        # Enrich profile attachements
+        for attachment in enrich_profile_attachment:
+            attachment_url = rest_url + "file/Candidate/" + str(candidate_id)
+            response = requests.put(url=attachment_url, json=attachment, params=params)
+
+    return failed_profiles
+
+
+def read_jobs(
+    adapter: LoggerAdapter,
+    parameters: ReadJobsParameters,
+    read_mode: t.Optional[ReadMode] = None,
+    read_from: t.Optional[str] = None,
+) -> t.Iterable[t.Dict]:
+
+    authentication = auth(
+        parameters.username,
+        parameters.password,
+        parameters.client_id,
+        parameters.client_secret,
+    )
+    start = 0
+
+    while True:
+
+        jobs_url = (
+            authentication["restUrl"]
+            + "search/JobOrder?query=(isOpen:true)&fields=*&BhRestToken="
+            + authentication["BhRestToken"]
+            + "&start="
+            + str(start)
+        )
+
+        response = requests.get(url=jobs_url)
+        if response.status_code // 100 != 2:
+            adapter.error(
+                "Failed to pull jobs from Bullhorn status_code={} response={}".format(
+                    response.status_code, response.text
+                )
+            )
+            raise Exception("Failed to pull jobs from Bullhorn")
+        response = response.json()
+
+        start = response["start"] + response["count"]
+        data = response["data"]
+
+        for job in data:
+            yield job
+
+        if start >= response["total"]:
+            break
+
+
+def read_profiles_parsing(
+    adapter: LoggerAdapter,
+    parameters: ReadProfileParameters,
+    read_mode: t.Optional[ReadMode] = None,
+    read_from: t.Optional[str] = None,
+) -> t.Iterable[t.Dict]:
+
+    authentication = auth(
+        parameters.username,
+        parameters.password,
+        parameters.client_id,
+        parameters.client_secret,
+    )
+    start = 0
+    while True:
+
+        profiles_url = (
+            authentication["restUrl"] + "myCandidates?fields=*&start=" + str(start)
+        )
+        headers = {"BhRestToken": authentication["BhRestToken"]}
+
+        response = requests.get(url=profiles_url, headers=headers)
+        if response.status_code // 100 != 2:
+            adapter.error(
+                "Failed to pull profiles from Bullhorn"
+                " status_code={} response={}".format(
+                    response.status_code, response.text
+                )
+            )
+            raise Exception("Failed to pull profiles from Bullhorn")
+        response = response.json()
+
+        start = response["start"] + response["count"]
+        data = response["data"]
+        total = response["total"]
+
+        for profile in data:
+
+            profile["cvFile"] = None
+            url_files = (
+                authentication["restUrl"]
+                + "entityFiles/Candidate/"
+                + str(profile["id"])
+            )
+            headers = {"BhRestToken": authentication["BhRestToken"]}
+            response = requests.get(url=url_files, headers=headers)
+            response = response.json()
+
+            last_cv = None
+            curr_entity = None
+            if len(response["EntityFiles"]) > 0:
+                for entity_file in response["EntityFiles"]:
+                    if entity_file["type"] == "Resume":
+                        if not curr_entity:
+                            curr_entity = entity_file
+                            last_cv = entity_file["id"]
+                        elif curr_entity["dateAdded"] < entity_file["dateAdded"]:
+                            curr_entity = entity_file
+                            last_cv = entity_file["id"]
+
+            if last_cv is not None:
+
+                url_cv = (
+                    authentication["restUrl"]
+                    + "/file/Candidate/"
+                    + str(profile["id"])
+                    + "/"
+                    + str(last_cv)
+                    + "/raw"
+                )
+                response = requests.get(url=url_cv, headers=headers)
+
+                file = response.content
+                with open("cv_file.pdf", "wb") as f:
+                    f.write(file)
+
+                with open("cv_file.pdf", "rb") as f:
+                    profile_file = f.read()
+                    profile["cvFile"] = profile_file
+
+                yield profile
+
+        if start >= total:
+            break
+
+
+def read_profiles(
+    adapter: LoggerAdapter,
+    parameters: ReadProfileParameters,
+    read_mode: t.Optional[ReadMode] = None,
+    read_from: t.Optional[str] = None,
+) -> t.Iterable[t.Dict]:
+
+    authentication = auth(
+        parameters.username,
+        parameters.password,
+        parameters.client_id,
+        parameters.client_secret,
+    )
+    fields = """firstName,lastName,name,email,mobile,dateOfBirth,gender,address,dateAvailable,status,employeeType,activePlacements,
+    skillSet,id,educations,workHistories"""
+    start = 0
+    while True:
+
+        profiles_url = (
+            authentication["restUrl"]
+            + "myCandidates?fields="
+            + fields
+            + "&start="
+            + str(start)
+        )
+        headers = {"BhRestToken": authentication["BhRestToken"]}
+
+        response = requests.get(url=profiles_url, headers=headers)
+        if response.status_code // 100 != 2:
+            adapter.error(
+                "Failed to pull profiles from Bullhorn"
+                " status_code={} response={}".format(
+                    response.status_code, response.text
+                )
+            )
+            raise Exception("Failed to pull profiles from Bullhorn")
+        response = response.json()
+
+        start = response["start"] + response["count"]
+        total = response["total"]
+        data = response["data"]
+
+        for profile in data:
+            # Enrich profile education
+            education_ids = []
+            educations = []
+            for ed in profile["educations"]["data"]:
+                education_ids.append(ed["id"])
+            for id in education_ids:
+                education_url = (
+                    authentication["restUrl"]
+                    + "entity/CandidateEducation/"
+                    + str(id)
+                    + "?fields=*"
+                )
+                response = requests.get(url=education_url, headers=headers)
+                response = response.json()
+                educations.append(response["data"])
+
+            # Enrich profile work history
+            work_history_ids = []
+            work_histories = []
+            for work_history in profile["workHistories"]["data"]:
+                work_history_ids.append(work_history["id"])
+            for id in work_history_ids:
+                work_history_url = (
+                    authentication["restUrl"]
+                    + "entity/CandidateWorkHistory/"
+                    + str(id)
+                    + "?fields=*"
+                )
+                response = requests.get(url=work_history_url, headers=headers)
+                response = response.json()
+                work_histories.append(response["data"])
+
+            profile["educations"] = educations
+            profile["workHistories"] = work_histories
+
+            yield profile
+
+        if start >= total:
+            break
+
+
+BullhornProfileWarehouse = Warehouse(
+    name="Bullhorn Profiles",
+    data_schema=BullhornProfile,
+    data_type=DataType.profile,
+    write=WarehouseWriteAction(
+        parameters=WriteProfilesParameters, function=write, endpoints=[]
+    ),
+    read=WarehouseReadAction(
+        parameters=ReadProfileParameters, function=read_profiles, endpoints=[]
+    ),
+)
+
+BullhornProfileParsingWarehouse = Warehouse(
+    name="Bullhorn Profiles",
+    data_schema=BullhornProfile,
+    data_type=DataType.profile,
+    read=WarehouseReadAction(
+        parameters=ReadProfileParameters, function=read_profiles_parsing, endpoints=[]
+    ),
+)
+
+BullhornJobWarehouse = Warehouse(
+    name="Bullhorn Jobs",
+    data_schema=BullhornJob,
+    data_type=DataType.job,
+    read=WarehouseReadAction(
+        parameters=ReadJobsParameters,
+        function=read_jobs,
+        endpoints=[],
+    ),
+)


Test patch:
diff --git a/src/hrflow_connectors/connectors/bullhorn/test-config.yaml b/src/hrflow_connectors/connectors/bullhorn/test-config.yaml
new file mode 100644
index 000000000..6c6138b3d
--- /dev/null
+++ b/src/hrflow_connectors/connectors/bullhorn/test-config.yaml
@@ -0,0 +1,52 @@
+actions:
+  pull_profile:
+    - id: valid_parameters
+      origin_parameters:
+        username: $__USERNAME
+        password: $__PASSWORD
+        client_id: $__CLIENT_ID
+        client_secret: $__CLIENT_SECRET
+      target_parameters:
+        api_secret: $__API_SECRET
+        api_user: $__API_USER
+        source_key: $__SOURCE_KEY
+        only_edit_fields: []
+      status: success
+  pull_profile_parsing:
+    - id: valid_parameters
+      origin_parameters:
+        username: $__USERNAME
+        password: $__PASSWORD
+        client_id: $__CLIENT_ID
+        client_secret: $__CLIENT_SECRET
+      target_parameters:
+        api_secret: $__API_SECRET
+        api_user: $__API_USER
+        source_key: $__PARSING_SOURCE_KEY
+        only_edit_fields: []
+      status: success
+  pull_job:
+    - id: valid_parameters
+      origin_parameters:
+        username: $__USERNAME
+        password: $__PASSWORD
+        client_id: $__CLIENT_ID
+        client_secret: $__CLIENT_SECRET
+      target_parameters:
+        api_secret: $__API_SECRET
+        api_user: $__API_USER
+        board_key: $__BOARD_KEY
+      status: success
+  push_profile:
+    - id: valid_parameters
+      origin_parameters:
+        api_secret: $__API_SECRET
+        api_user: $__API_USER
+        source_key: $__SOURCE_KEY_PUSH
+        profile_key: $__PROFILE_KEY_PUSH
+      target_parameters:
+        username: $__USERNAME
+        password: $__PASSWORD
+        client_id: $__CLIENT_ID
+        client_secret: $__CLIENT_SECRET
+      status: success
\ No newline at end of file