orionapi 2.29.0__tar.gz → 2.30.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orionapi
3
- Version: 2.29.0
3
+ Version: 2.30.2
4
4
  Summary: A python interface for the Orion Advisors platform web API
5
5
  Author: spencerogden-dsam
6
6
  Author-email: 67068943+spencerogden-dsam@users.noreply.github.com
@@ -1,4 +1,4 @@
1
- __version__ = "2.29.0"
1
+ __version__ = "2.30.2"
2
2
 
3
3
  import logging
4
4
  import re
@@ -2810,6 +2810,11 @@ class EclipseV1(EclipseBase):
2810
2810
  """Get portfolio details by ID.
2811
2811
 
2812
2812
  Returns dict with 'general', 'teams', 'issues', 'summary' sections.
2813
+ The 'general' section carries ``teamIds`` / ``primaryTeamId``. The
2814
+ 'teams' section's per-record field spelling is unspecified upstream
2815
+ (``id`` vs ``teamId``, ``name`` vs ``teamName``) — accept both, or use
2816
+ :meth:`get_portfolio_teams` for a normalized ``{id, name, is_primary}``
2817
+ view.
2813
2818
  """
2814
2819
  res = self.api_request(f"{self.base_url}/portfolio/portfolios/{portfolio_id}")
2815
2820
  return res.json()
@@ -4838,46 +4843,46 @@ class EclipseV1(EclipseBase):
4838
4843
  def set_portfolio_tradeable(self, portfolio_id, tradeable=True, sync=True):
4839
4844
  """Set whether trading is allowed for a portfolio.
4840
4845
 
4846
+ Uses the v2 ``setPortfolioTradeBlock`` action, which accepts only
4847
+ ``{"id", "doNotTrade"}`` and therefore cannot touch any other portfolio
4848
+ field. The older approach (GET the portfolio, rebuild the v1 PUT payload
4849
+ by hand) rejected portfolios with no primary team and risked stripping
4850
+ teams, trading instructions and auto-rebalance settings that the
4851
+ rebuilt payload did not carry. Verified live 2026-09-10 on portfolio
4852
+ 209: the flag flips and every ``general``/``teams`` field is unchanged.
4853
+
4841
4854
  Args:
4842
4855
  portfolio_id: Portfolio ID
4843
4856
  tradeable: True to allow trading, False to block (default True)
4844
4857
  sync: Wait for analytics to complete (default True)
4845
4858
 
4846
4859
  Returns:
4847
- dict: Updated portfolio details
4860
+ dict: Updated portfolio details (same shape as :meth:`get_portfolio`)
4848
4861
  """
4849
4862
  if not isinstance(portfolio_id, int) or portfolio_id < 1:
4850
4863
  raise ValueError("portfolio_id must be a positive integer")
4851
4864
  if not isinstance(tradeable, bool):
4852
4865
  raise ValueError("tradeable must be a boolean")
4853
4866
 
4854
- # Get current portfolio to preserve other fields
4855
- portfolio = self.get_portfolio(portfolio_id)
4856
- general = portfolio.get("general", {})
4857
-
4858
- # Build payload preserving existing fields
4859
- payload = {
4860
- "name": general.get("portfolioName"),
4861
- "modelId": general.get("modelId"),
4862
- "isSleevePortfolio": general.get("sleevePortfolio", False),
4863
- "doNotTrade": 0 if tradeable else 1,
4864
- "tags": general.get("tags", ""),
4865
- "teamIds": general.get("teamIds", []),
4866
- "primaryTeamId": general.get("primaryTeamId"),
4867
- }
4868
-
4869
- res = self.api_request(
4870
- f"{self.base_url}/portfolio/portfolios/{portfolio_id}",
4867
+ self.api_request(
4868
+ f"{self.base_url_v2}/Portfolio/Portfolios/action/setPortfolioTradeBlock",
4871
4869
  requests.put,
4872
- json=payload,
4870
+ json=[{"id": portfolio_id, "doNotTrade": not tradeable}],
4873
4871
  )
4874
- result = res.json()
4872
+ result = self.get_portfolio(portfolio_id)
4875
4873
  self._maybe_wait_for_analytics(sync)
4876
4874
  return result
4877
4875
 
4878
4876
  def set_account_tradeable(self, account_id, trade_restriction="tradeable", sync=True):
4879
4877
  """Set trading restrictions for an account.
4880
4878
 
4879
+ Uses the v2 ``setAccountTradeBlock`` action, which accepts only
4880
+ ``{"id", "isDoNotBuySell", "isCustodialRestriction"}`` and therefore
4881
+ cannot touch any other account field. The older approach rebuilt the
4882
+ v1 ``PUT /account/accounts/{id}`` payload from ``generalSection`` of the
4883
+ GET DTO, but the account DTO is flat (no ``generalSection``), so it was
4884
+ sending ``accountName: null`` / ``portfolioId: null`` alongside the flags.
4885
+
4881
4886
  Args:
4882
4887
  account_id: Internal Eclipse account ID
4883
4888
  trade_restriction: One of:
@@ -4887,7 +4892,8 @@ class EclipseV1(EclipseBase):
4887
4892
  sync: Wait for analytics to complete (default True)
4888
4893
 
4889
4894
  Returns:
4890
- dict: Updated account details
4895
+ dict: Updated account details (same shape as :meth:`get_account_details`;
4896
+ the flags are ``isDoNotBuySell`` and ``isCustodialRestriction``)
4891
4897
 
4892
4898
  Note:
4893
4899
  These are mutually exclusive options - only one can be active at a time.
@@ -4895,35 +4901,27 @@ class EclipseV1(EclipseBase):
4895
4901
  if not isinstance(account_id, int) or account_id < 1:
4896
4902
  raise ValueError("account_id must be a positive integer")
4897
4903
 
4898
- valid_restrictions = ["tradeable", "block_advisor", "block_custodian"]
4899
- if trade_restriction not in valid_restrictions:
4900
- raise ValueError(f"trade_restriction must be one of {valid_restrictions}")
4901
-
4902
- # Get current account details
4903
- account = self.get_account_details(account_id)
4904
- general = account.get("generalSection", {})
4905
-
4906
- # Build payload
4907
- payload = {
4908
- "accountName": general.get("accountName"),
4909
- "portfolioId": general.get("portfolioId"),
4904
+ flags = {
4905
+ "tradeable": (False, False),
4906
+ "block_advisor": (True, False),
4907
+ "block_custodian": (False, True),
4910
4908
  }
4909
+ if trade_restriction not in flags:
4910
+ raise ValueError(f"trade_restriction must be one of {list(flags)}")
4911
+ do_not_buy_sell, custodial_restriction = flags[trade_restriction]
4911
4912
 
4912
- # Set flags based on restriction type
4913
- if trade_restriction == "tradeable":
4914
- payload["doNotTrade"] = 0
4915
- payload["doNotTradeCustodian"] = 0
4916
- elif trade_restriction == "block_advisor":
4917
- payload["doNotTrade"] = 1
4918
- payload["doNotTradeCustodian"] = 0
4919
- elif trade_restriction == "block_custodian":
4920
- payload["doNotTrade"] = 0
4921
- payload["doNotTradeCustodian"] = 1
4922
-
4923
- res = self.api_request(
4924
- f"{self.base_url}/account/accounts/{account_id}", requests.put, json=payload
4913
+ self.api_request(
4914
+ f"{self.base_url_v2}/Account/Accounts/action/setAccountTradeBlock",
4915
+ requests.put,
4916
+ json=[
4917
+ {
4918
+ "id": account_id,
4919
+ "isDoNotBuySell": do_not_buy_sell,
4920
+ "isCustodialRestriction": custodial_restriction,
4921
+ }
4922
+ ],
4925
4923
  )
4926
- result = res.json()
4924
+ result = self.get_account_details(account_id)
4927
4925
  self._maybe_wait_for_analytics(sync)
4928
4926
  return result
4929
4927
 
@@ -6663,6 +6661,108 @@ class EclipseV1(EclipseBase):
6663
6661
  json=payload,
6664
6662
  ).json()
6665
6663
 
6664
+ # --- Admin: Teams (v1 reads) ---
6665
+ # The v1 team routes are the reliable team read on tenants where teams were
6666
+ # not created with an externalId — EclipseV2.get_teams returns [] there
6667
+ # (see its docstring). teamIds/primaryTeamId are required on the portfolio
6668
+ # record, so these are the reads that unblock create_portfolio.
6669
+
6670
+ def get_teams(self, is_active=None):
6671
+ """Get all teams (``GET /admin/teams``).
6672
+
6673
+ Args:
6674
+ is_active: When set, passed as ``isActive`` (e.g. ``True`` returns
6675
+ active teams only). When None (default), the parameter is
6676
+ omitted and all teams are returned.
6677
+
6678
+ Returns:
6679
+ list: Team dicts. At minimum ``{id, name}``; the full record also
6680
+ carries ``portfolioAccess``, ``modelAccess``, ``status``,
6681
+ ``numberOfUsers``, ``numberOfModels``, ``numberOfPortfolios``,
6682
+ ``numberOfAdvisors``, ``isDeleted``, ``createdOn/By``,
6683
+ ``editedOn/By``.
6684
+ """
6685
+ params = {}
6686
+ if is_active is not None:
6687
+ params["isActive"] = str(bool(is_active)).lower()
6688
+ res = self.api_request(f"{self.base_url}/admin/teams", params=params)
6689
+ return res.json()
6690
+
6691
+ def get_team(self, team_id):
6692
+ """Get details for a single team (``GET /admin/teams/{id}``).
6693
+
6694
+ Args:
6695
+ team_id: Eclipse team ID
6696
+
6697
+ Returns:
6698
+ dict: Team detail record
6699
+ """
6700
+ res = self.api_request(f"{self.base_url}/admin/teams/{team_id}")
6701
+ return res.json()
6702
+
6703
+ def get_team_portfolios(self, team_id):
6704
+ """Get portfolios associated with a team (``GET /admin/teams/{id}/portfolios``).
6705
+
6706
+ Args:
6707
+ team_id: Eclipse team ID
6708
+
6709
+ Returns:
6710
+ list: Portfolio dicts (``{id, name, isDeleted, createdOn/By, editedOn/By}``)
6711
+ """
6712
+ res = self.api_request(f"{self.base_url}/admin/teams/{team_id}/portfolios")
6713
+ return res.json()
6714
+
6715
+ def get_team_primary_portfolios(self, team_id):
6716
+ """Get portfolios where the team is set as primary
6717
+ (``GET /admin/teams/{id}/primaryPortfolios``).
6718
+
6719
+ Args:
6720
+ team_id: Eclipse team ID
6721
+
6722
+ Returns:
6723
+ list: Portfolio dicts (``{id, name, createdOn/By, editedOn/By}``)
6724
+ """
6725
+ res = self.api_request(f"{self.base_url}/admin/teams/{team_id}/primaryPortfolios")
6726
+ return res.json()
6727
+
6728
+ def get_portfolio_teams(self, portfolio_id):
6729
+ """Get the teams stamped on a portfolio, normalized.
6730
+
6731
+ Reads the ``teams`` section of ``GET /portfolio/portfolios/{id}``
6732
+ (see :meth:`get_portfolio`) and normalizes each record to
6733
+ ``{id, name, is_primary}``. Unlike the ``/admin/teams`` routes, this
6734
+ works for a credential that is on no team at all, so it is the team
6735
+ read of last resort for service accounts.
6736
+
6737
+ The upstream field spelling is unspecified (``id`` vs ``teamId``,
6738
+ ``name`` vs ``teamName``), so both are accepted. ``is_primary`` is
6739
+ derived from ``general.primaryTeamId`` when present, falling back to
6740
+ the record's own ``isPrimaryTeam`` flag.
6741
+
6742
+ Args:
6743
+ portfolio_id: Portfolio ID
6744
+
6745
+ Returns:
6746
+ list: ``{id, name, is_primary}`` dicts, one per team on the portfolio
6747
+ """
6748
+ portfolio = self.get_portfolio(portfolio_id)
6749
+ primary_team_id = portfolio.get("general", {}).get("primaryTeamId")
6750
+ normalized = []
6751
+ for team in portfolio.get("teams") or []:
6752
+ team_id = team.get("id", team.get("teamId"))
6753
+ if primary_team_id is not None:
6754
+ is_primary = team_id == primary_team_id
6755
+ else:
6756
+ is_primary = bool(team.get("isPrimaryTeam"))
6757
+ normalized.append(
6758
+ {
6759
+ "id": team_id,
6760
+ "name": team.get("name", team.get("teamName")),
6761
+ "is_primary": is_primary,
6762
+ }
6763
+ )
6764
+ return normalized
6765
+
6666
6766
  # --- Admin token (v1) ---
6667
6767
 
6668
6768
  def get_firm_token(self, payload):
@@ -10107,13 +10207,19 @@ class EclipseV2(EclipseBase):
10107
10207
  # --- Team / ServiceTeams / User ---
10108
10208
 
10109
10209
  def get_teams(self, external_id=None):
10110
- """Get teams.
10210
+ """Get teams (v2 ``Team/Team/GetTeams``).
10211
+
10212
+ Warning:
10213
+ Returns ``[]`` (HTTP 200) on tenants where teams were not created
10214
+ with an ``externalId`` — live-verified 2026-08-17. Prefer the v1
10215
+ route (:meth:`EclipseV1.get_teams`, ``GET /admin/teams``), which
10216
+ the :class:`Eclipse` unifier resolves to.
10111
10217
 
10112
10218
  Args:
10113
10219
  external_id: Optional external ID filter (maps to ``externalId``)
10114
10220
 
10115
10221
  Returns:
10116
- list: Team dicts
10222
+ list: Team dicts (``{teamId, teamName, isPrimaryTeam, ...}``)
10117
10223
  """
10118
10224
  params = {}
10119
10225
  if external_id is not None:
@@ -11713,6 +11819,21 @@ class Eclipse(EclipseBase):
11713
11819
  internal_account_id=internal_account_id,
11714
11820
  )
11715
11821
 
11822
+ # --- v1-preferred overrides (documented best-of-both choices) ----------------
11823
+
11824
+ def get_teams(self, is_active=None):
11825
+ """Teams via the v1 route (best: v2 ``GetTeams`` is empty on some tenants).
11826
+
11827
+ Delegates to :meth:`EclipseV1.get_teams` (``GET /admin/teams``),
11828
+ returning ``{id, name, ...}`` records. **Behavior change in 2.30.0:**
11829
+ this name previously resolved to :meth:`EclipseV2.get_teams`, which
11830
+ returns ``[]`` (with differently spelled ``{teamId, teamName,
11831
+ isPrimaryTeam}`` records) on tenants where teams were not created with
11832
+ an ``externalId``. Use ``self.v2.get_teams(external_id=...)`` for the
11833
+ v2 surface explicitly.
11834
+ """
11835
+ return self.v1.get_teams(is_active=is_active)
11836
+
11716
11837
 
11717
11838
  class EclipseAPI(Eclipse):
11718
11839
  """Deprecated alias of :class:`Eclipse` (best-of-both client).
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "orionapi"
3
- version = "2.29.0"
3
+ version = "2.30.2"
4
4
  description = "A python interface for the Orion Advisors platform web API"
5
5
  authors = ["spencerogden-dsam <67068943+spencerogden-dsam@users.noreply.github.com>"]
6
6
 
File without changes