Closed codearranger closed 1 month ago
Here's my python code I used to create the fields
twenty_api.py
import requests
import logging
logger = logging.getLogger(__name__)
class TwentyAPI:
def __init__(self, base_url, api_key):
self.base_url = base_url
self.headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
def _request(self, method, endpoint, json=None, params=None):
url = f"{self.base_url}{endpoint}"
logger.debug(f"Sending {method} request to: {url}")
logger.debug(f"Parameters: {params}")
logger.debug(f"JSON data: {json}")
response = requests.request(method, url, headers=self.headers, json=json, params=params)
logger.debug(f"Response status code: {response.status_code}")
logger.debug(f"Response content: {response.text}")
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.error(f"HTTP Error: {e}")
logger.error(f"Response headers: {response.headers}")
raise
return response.json()
def _paginated_request(self, endpoint, params=None):
all_data = []
params = params or {}
while True:
response = self._request("GET", endpoint, params=params)
data = response.get('data', {})
if endpoint.strip('/') in data:
all_data.extend(data[endpoint.strip('/')])
else:
break # No more data to fetch
page_info = response.get('pageInfo', {})
if not page_info.get('hasNextPage'):
break
params['starting_after'] = page_info.get('endCursor')
return all_data
# API Keys
def create_api_key(self, data):
return self._request("POST", "/apiKeys", data=data)
def get_api_key(self, id, depth=None):
params = {"depth": depth} if depth else None
return self._request("GET", f"/apiKeys/{id}", params=params)
def update_api_key(self, id, data):
return self._request("PATCH", f"/apiKeys/{id}", data=data)
def delete_api_key(self, id):
return self._request("DELETE", f"/apiKeys/{id}")
# Favorites
def create_favorite(self, data):
return self._request("POST", "/favorites", data=data)
def get_favorite(self, id, depth=None):
params = {"depth": depth} if depth else None
return self._request("GET", f"/favorites/{id}", params=params)
def update_favorite(self, id, data):
return self._request("PATCH", f"/favorites/{id}", data=data)
def delete_favorite(self, id):
return self._request("DELETE", f"/favorites/{id}")
def find_favorite_duplicates(self, data, depth=None):
params = {"depth": depth} if depth else None
return self._request("POST", "/favorites/duplicates", data=data, params=params)
# Persons
def create_person(self, data):
return self._request("POST", "/persons", data=data)
def get_person(self, id, depth=None):
params = {"depth": depth} if depth else None
return self._request("GET", f"/persons/{id}", params=params)
def update_person(self, id, data):
return self._request("PATCH", f"/persons/{id}", data=data)
def delete_person(self, id):
return self._request("DELETE", f"/persons/{id}")
# Notes
def create_note(self, data):
return self._request("POST", "/notes", data=data)
def get_note(self, id, depth=None):
params = {"depth": depth} if depth else None
return self._request("GET", f"/notes/{id}", params=params)
def update_note(self, id, data):
return self._request("PATCH", f"/notes/{id}", data=data)
def delete_note(self, id):
return self._request("DELETE", f"/notes/{id}")
# Add more methods for other endpoints as needed
def get_all_people(self, order_by=None, limit=None):
params = {}
if order_by:
params['order_by'] = f"{order_by}[AscNullsFirst]"
if limit:
params['first'] = limit
return self._paginated_request("/people", params=params)
def get_all_companies(self, order_by=None, filter=None, depth=None, limit=None):
params = {
"order_by": order_by,
"filter": filter,
"depth": depth,
"limit": limit
}
return self._paginated_request("/companies", params={k: v for k, v in params.items() if v is not None})
def get_all_opportunities(self, order_by=None, filter=None, depth=None, limit=None):
params = {
"order_by": order_by,
"filter": filter,
"depth": depth,
"limit": limit
}
return self._paginated_request("/opportunities", params={k: v for k, v in params.items() if v is not None})
# Metadata API calls
# Objects
def get_objects(self, limit=None, starting_after=None, ending_before=None):
params = {
"limit": limit,
"starting_after": starting_after,
"ending_before": ending_before
}
return self._request("GET", "/metadata/objects", params={k: v for k, v in params.items() if v is not None})
def create_object(self, data):
return self._request("POST", "/metadata/objects", json=data)
def get_object(self, id):
return self._request("GET", f"/metadata/objects/{id}")
def update_object(self, id, data):
return self._request("PATCH", f"/metadata/objects/{id}", json=data)
def delete_object(self, id):
return self._request("DELETE", f"/metadata/objects/{id}")
# Fields
def get_fields(self, limit=None):
all_fields = []
params = {"limit": limit} if limit else {}
while True:
response = self._request("GET", "/metadata/fields", params=params)
data = response.get('data', {})
if 'fields' in data:
all_fields.extend(data['fields'])
else:
break # No more data to fetch
page_info = response.get('pageInfo', {})
if not page_info.get('hasNextPage'):
break
params['starting_after'] = page_info.get('endCursor')
logger.info(f"Retrieved {len(all_fields)} fields")
return all_fields
def create_field(self, data):
response = self._request("POST", "/metadata/fields", json=data)
created_field = response['data']['createOneField']
logger.info(f"Created field: {created_field['name']}")
return created_field
# Types can be found here: https://github.com/twentyhq/twenty/blob/main/packages/twenty-zapier/src/utils/data.types.ts
def get_field(self, id):
return self._request("GET", f"/metadata/fields/{id}")
def update_field(self, id, data):
return self._request("PATCH", f"/metadata/fields/{id}", json=data)
def delete_field(self, id):
return self._request("DELETE", f"/metadata/fields/{id}")
# Relations
def get_relations(self, limit=None, starting_after=None, ending_before=None):
params = {
"limit": limit,
"starting_after": starting_after,
"ending_before": ending_before
}
return self._request("GET", "/metadata/relations", params={k: v for k, v in params.items() if v is not None})
def create_relation(self, data):
return self._request("POST", "/metadata/relations", json=data)
def get_relation(self, id):
return self._request("GET", f"/metadata/relations/{id}")
def delete_relation(self, id):
return self._request("DELETE", f"/metadata/relations/{id}")
# Open API Schema
def get_open_api_schema(self):
return self._request("GET", "/metadata/open-api")
create_objects.py
import logging
from twenty_api import TwentyAPI
import os
from dotenv import load_dotenv
import uuid
# Set up logging
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Load environment variables
load_dotenv()
# Initialize the API client
api = TwentyAPI(
base_url=os.getenv("TWENTY_BASE_URL"),
api_key=os.getenv("TWENTY_API_KEY")
)
def ensure_custom_object_exists(name_singular, name_plural, label_singular, label_plural):
# Check if the object already exists
objects = api.get_objects()
existing_object = next((obj for obj in objects['data']['objects'] if obj['nameSingular'] == name_singular), None)
if existing_object:
logger.info(f"Custom object '{name_singular}' already exists.")
logger.debug(f"Existing object: {existing_object}")
return existing_object['id']
else:
# Create the custom object
new_object = api.create_object({
"nameSingular": name_singular,
"namePlural": name_plural,
"labelSingular": label_singular,
"labelPlural": label_plural,
"description": f"Custom object for {label_plural}",
"icon": "IconBuildingSkyscraper"
})
logger.info(f"Created custom object '{name_singular}'.")
return new_object['data']['createOneObject']['id']
def ensure_custom_field_exists(api, object_id, field_name, field_type, field_label, field_description, field_icon, options=None):
fields = api.get_fields()
existing_field = next((field for field in fields if field['name'] == field_name), None)
if existing_field:
logger.info(f"Field '{field_name}' already exists for object {object_id}")
return existing_field
new_field_data = {
"objectMetadataId": object_id,
"name": field_name,
"type": field_type,
"label": field_label,
"description": field_description,
"icon": field_icon,
"isCustom": True
}
if field_type == "SELECT" and options:
new_field_data["options"] = options
logger.debug(f"Creating new field with data: {new_field_data}")
created_field = api.create_field(new_field_data)
logger.info(f"Created new field '{field_name}' for object {object_id}")
return created_field
def ensure_custom_fields_exist(api, object_id, fields_to_create):
# Fetch all fields once
all_fields = api.get_fields()
for field_data in fields_to_create:
logger.debug(f"Ensuring field exists: {field_data[0]}")
field_name, field_type, field_label, field_description, field_icon, *options = field_data
existing_field = next((field for field in all_fields if field['name'] == field_name), None)
if existing_field:
logger.info(f"Field '{field_name}' already exists for object {object_id}")
continue
new_field_data = {
"objectMetadataId": object_id,
"name": field_name,
"type": field_type,
"label": field_label,
"description": field_description,
"icon": field_icon,
"isCustom": True
}
if field_type == "SELECT" and options:
new_field_data["options"] = options[0]
logger.debug(f"Creating new field with data: {new_field_data}")
created_field = api.create_field(new_field_data)
logger.info(f"Created new field '{field_name}' for object {object_id}")
def get_object_id_by_name(api, name_singular):
objects = api.get_objects()
object_data = next((obj for obj in objects['data']['objects'] if obj['nameSingular'] == name_singular), None)
if object_data:
return object_data['id']
return None
def main():
property_object_id = ensure_custom_object_exists(
name_singular="property",
name_plural="properties",
label_singular="Property",
label_plural="Properties"
)
fields_to_create = [
("rent", "CURRENCY", "Rent", "Rent amount", "IconCurrencyDollar"),
("fmrrent", "CURRENCY", "HUD Fair Market Rent", "HUD Fair Market Rent amount", "IconCurrencyDollar"),
("piti", "CURRENCY", "PITI", "Principal, Interest, Taxes, and Insurance", "IconCurrencyDollar"),
("cashflow", "CURRENCY", "Comp Cash Flow", "(Avg Comp Rent - PITI) X .75", "IconCurrencyDollar"),
("mortgagepayment", "CURRENCY", "Mortgage Payment", "Monthly mortgage payment", "IconCurrencyDollar"),
("listprice", "CURRENCY", "ListPrice", "Listing price of the property", "IconCurrencyDollar"),
("mortgagebalance", "CURRENCY", "Mortgage Balance", "Remaining mortgage balance", "IconCurrencyDollar"),
("equity", "CURRENCY", "Equity", "Property equity", "IconCurrencyDollar"),
("compvalue", "CURRENCY", "Comp Value", "Comparable property value", "IconCurrencyDollar"),
("pricedifference", "CURRENCY", "Price Difference", "Comp value - listprice", "IconCurrencyDollar"),
("monthlytaxes", "CURRENCY", "Monthly Taxes", "Monthly property taxes", "IconCurrencyDollar"),
("monthlyhoa", "CURRENCY", "Monthly HOA", "Monthly HOA fees", "IconCurrencyDollar"),
("monthlyinsurance", "CURRENCY", "Monthly Insurance", "Monthly insurance cost", "IconCurrencyDollar"),
("ownerrent", "CURRENCY", "Owner Rent", "Rent amount set by owner", "IconCurrencyDollar"),
("offermonthly", "CURRENCY", "Offer Monthly", "Monthly offer amount", "IconCurrencyDollar"),
("offercashflow", "CURRENCY", "Offer Cash Flow", "Cash flow from offer", "IconCurrencyDollar"),
("offertotalpayments", "CURRENCY", "Offer Total Payments", "Total payments for offer", "IconCurrencyDollar"),
("offertotalprincipal", "CURRENCY", "Offer Total Principal Paid", "Total principal paid for offer", "IconCurrencyDollar"),
("offertotaltoseller", "CURRENCY", "Offer Total Paid to Seller", "Total amount paid to seller", "IconCurrencyDollar"),
("offerdownpayment", "CURRENCY", "Offer Down Payment", "Down payment for offer", "IconCurrencyDollar"),
("offerballoonpayment", "CURRENCY", "Offer Balloon Payment", "Balloon payment for offer", "IconCurrencyDollar"),
("offerinterestpaid", "CURRENCY", "Offer Interest Paid", "Total interest paid for offer", "IconCurrencyDollar"),
("offerprice", "CURRENCY", "Offer Price", "Price of the offer", "IconCurrencyDollar"),
("gy23price", "CURRENCY", "Gross Yield @ 23% Price", "Gross yield at 23% price", "IconCurrencyDollar"),
("gy30price", "CURRENCY", "Gross Yield @ 30% Price", "Gross yield at 30% price", "IconCurrencyDollar"),
("equitypct", "NUMBER", "Equity Percent", "Equity percentage", "IconPercentage"),
("interestrate", "NUMBER", "Interest Rate", "Interest rate percentage", "IconPercentage"),
("offerannualrate", "NUMBER", "Offer Annual Rate", "Annual rate for offer", "IconPercentage"),
("grossyield", "NUMBER", "Gross Yield", "Gross yield percentage", "IconPercentage"),
("homestatus", "SELECT", "Home Status", "Current status of the home", "IconHome", [
{"color": "green", "id": str(uuid.uuid4()), "label": "FOR_SALE", "position": 0, "value": "FOR_SALE"},
{"color": "turquoise", "id": str(uuid.uuid4()), "label": "SOLD", "position": 1, "value": "SOLD"}
]),
("propertytype", "TEXT", "Property Type", "Type of property", "IconBuilding"),
("zillowlink", "LINKS", "Zillow Link", "Link to Zillow listing", "IconLink"),
("propstreamlink", "LINKS", "Propstream Link", "Link to Propstream", "IconLink"),
("dealchecklink", "LINKS", "Dealcheck Link", "Link to Dealcheck", "IconLink"),
("spotcrimelink", "LINKS", "Spotcrime Link", "Link to Spotcrime", "IconLink"),
("realtorlink", "LINKS", "Realtor Link", "Link to Realtor listing", "IconLink"),
("femafloodmap", "LINKS", "FEMA Flood Map", "Link to FEMA flood map", "IconMap"),
("streetaddress", "TEXT", "Street Address", "Property street address", "IconMapPin"),
("city", "TEXT", "City", "Property city", "IconBuilding"),
("state", "TEXT", "State", "Property state", "IconMap"),
("zipcode", "TEXT", "Zipcode", "Property zipcode", "IconMapPin"),
("county", "TEXT", "County", "Property county", "IconMap"),
("compcount", "NUMBER", "Comp Count", "Number of comparable properties", "IconCalculator"),
("yearbuilt", "NUMBER", "Year Built", "Year the property was built", "IconCalendar"),
("bedrooms", "NUMBER", "Bedrooms", "Number of bedrooms", "IconBed"),
("offerballoonyears", "NUMBER", "Offer Balloon Years", "Number of years for balloon payment", "IconCalendar"),
("offertermyears", "NUMBER", "Offer Term Years", "Number of years for offer term", "IconCalendar"),
("dateposted", "DATE", "Orig Date Posted", "Original date the property was posted", "IconCalendar"),
("saledate", "DATE", "Sale Date", "Date of sale", "IconCalendar"),
("livingarea", "NUMBER", "Sq ft", "Living area in square feet", "IconRuler"),
("bathrooms", "NUMBER", "Bathrooms", "Number of bathrooms", "IconBath")
]
ensure_custom_fields_exist(api, property_object_id, fields_to_create)
# Look up the Opportunities object ID
opportunities_object_id = get_object_id_by_name(api, "opportunity")
if not opportunities_object_id:
logger.error("Opportunities object not found")
return
# Add relation to Opportunities object
relation_data = {
"fromDescription": "Linked Properties",
"fromIcon": "IconUsers",
"fromLabel": "Properties",
"fromName": "properties",
"fromObjectMetadataId": property_object_id,
"relationType": "ONE_TO_MANY",
"toDescription": None,
"toIcon": "IconBuildingWarehouse",
"toLabel": "Opportunity",
"toName": "opportunity",
"toObjectMetadataId": opportunities_object_id
}
try:
created_relation = api.create_relation(relation_data)
logger.info(f"Created relation between Property and Opportunities objects")
except Exception as e:
logger.error(f"Failed to create relation: {str(e)}")
logger.info("Custom object, fields, and relation have been created or verified.")
if __name__ == "__main__":
try:
main()
except Exception as e:
logger.exception("An error occurred during execution:")
It seems to break once I add about 20 new fields
@joecryptotoo this is a limitation due to pg_graphql an extension we rely on. Some fields are "composite" (e.g. address create address street, address city, address postcode, etc. ; currency creates 2 field for value + currency ; Links creates 2 columns also). You're probably quite close to the limit. 20 fields should be okay but it seems you have more.
@Weiko is working on removing our dependency to pg_graphql, this will be completed in the next couple of weeks. So probably by end of September this should be fixed
Closing as this will be solved by end of month
Bug Description
After creating a new object with 53 new custom fields the UI fails to load and the logs are filled with messages like this: