Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Add EVENTS_INGESTED to meters_type

Revision ID: b2c3d4e5f7a8
Revises: a1b2c3d4e5f7
Create Date: 2026-05-19 18:30:00.000000
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "b2c3d4e5f7a8"
down_revision: Union[str, None] = "a1b2c3d4e5f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

ENUM_NAME = "meters_type"
TEMP_ENUM_NAME = "meters_type_temp"
TABLE_NAME = "meters"
COLUMN_NAME = "key"

NEW_ENUM_LABELS = (
"USERS",
"EVALUATIONS_RUN",
"TRACES_INGESTED",
"TRACES_RETRIEVED",
"CREDITS_CONSUMED",
"EVENTS_INGESTED",
)

OLD_ENUM_LABELS = (
"USERS",
"EVALUATIONS_RUN",
"TRACES_INGESTED",
"TRACES_RETRIEVED",
"CREDITS_CONSUMED",
)


def _replace_enum(labels: tuple[str, ...]) -> None:
op.execute(
sa.text(
f"CREATE TYPE {TEMP_ENUM_NAME} AS ENUM ("
+ ", ".join(f"'{label}'" for label in labels)
+ ")"
)
)
op.execute(
sa.text(
f"ALTER TABLE {TABLE_NAME} "
f"ALTER COLUMN {COLUMN_NAME} TYPE {TEMP_ENUM_NAME} "
f"USING {COLUMN_NAME}::text::{TEMP_ENUM_NAME}"
)
)
op.execute(sa.text(f"DROP TYPE {ENUM_NAME}"))
op.execute(sa.text(f"ALTER TYPE {TEMP_ENUM_NAME} RENAME TO {ENUM_NAME}"))


def upgrade() -> None:
_replace_enum(NEW_ENUM_LABELS)


def downgrade() -> None:
op.execute(
sa.text(
f"DELETE FROM {TABLE_NAME} WHERE {COLUMN_NAME}::text = 'EVENTS_INGESTED'"
)
)
_replace_enum(OLD_ENUM_LABELS)
7 changes: 7 additions & 0 deletions api/ee/src/core/entitlements/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class Tracker(str, Enum):

class Flag(str, Enum):
RBAC = "rbac"
AUDIT = "audit"
ACCESS = "access"
DOMAINS = "domains"
SSO = "sso"
Expand Down Expand Up @@ -347,6 +348,7 @@ class Throttle(BaseModel):
DefaultPlan.CLOUD_V0_HOBBY: {
Tracker.FLAGS: {
Flag.RBAC: False,
Flag.AUDIT: False,
Flag.ACCESS: False,
Flag.DOMAINS: False,
Flag.SSO: False,
Expand Down Expand Up @@ -437,6 +439,7 @@ class Throttle(BaseModel):
DefaultPlan.CLOUD_V0_PRO: {
Tracker.FLAGS: {
Flag.RBAC: False,
Flag.AUDIT: False,
Flag.ACCESS: False,
Flag.DOMAINS: False,
Flag.SSO: False,
Expand Down Expand Up @@ -524,6 +527,7 @@ class Throttle(BaseModel):
DefaultPlan.CLOUD_V0_BUSINESS: {
Tracker.FLAGS: {
Flag.RBAC: True,
Flag.AUDIT: True,
Flag.ACCESS: True,
Flag.DOMAINS: True,
Flag.SSO: True,
Expand Down Expand Up @@ -609,6 +613,7 @@ class Throttle(BaseModel):
DefaultPlan.CLOUD_V0_AGENTA_AI: {
Tracker.FLAGS: {
Flag.RBAC: True,
Flag.AUDIT: True,
Flag.ACCESS: True,
Flag.DOMAINS: True,
Flag.SSO: True,
Expand Down Expand Up @@ -645,6 +650,7 @@ class Throttle(BaseModel):
DefaultPlan.SELF_HOSTED_ENTERPRISE: {
Tracker.FLAGS: {
Flag.RBAC: True,
Flag.AUDIT: True,
Flag.ACCESS: True,
Flag.DOMAINS: True,
Flag.SSO: True,
Expand Down Expand Up @@ -696,6 +702,7 @@ class Throttle(BaseModel):
Flag.ACCESS,
Flag.DOMAINS,
Flag.SSO,
Flag.AUDIT,
],
Tracker.GAUGES: [
Gauge.USERS,
Expand Down
1 change: 1 addition & 0 deletions api/ee/src/core/meters/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Meters(str, Enum):
TRACES_INGESTED = Counter.TRACES_INGESTED.value
TRACES_RETRIEVED = Counter.TRACES_RETRIEVED.value
CREDITS_CONSUMED = Counter.CREDITS_CONSUMED.value
EVENTS_INGESTED = Counter.EVENTS_INGESTED.value
# GAUGES
USERS = Gauge.USERS.value

Expand Down
8 changes: 0 additions & 8 deletions api/ee/src/core/subscriptions/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
Pricing entries provide Stripe line items and the free-plan marker.
"""

from os import getenv
from typing import Any, Dict, List, Optional

from pydantic import BaseModel, ConfigDict, ValidationError
Expand Down Expand Up @@ -422,19 +421,12 @@ def require_pricing(
return line_items

plan = slug or "<missing>"
legacy_hint = ""
if env.billing.pricing is None and getenv("STRIPE_PRICING"):
legacy_hint = (
" Legacy STRIPE_PRICING is ignored on this branch; migrate it to "
"AGENTA_BILLING_PRICING."
)

raise ValueError(
f"{purpose} requires Stripe line items for plan '{plan}', but none "
"are configured. Set AGENTA_BILLING_PRICING with an entry for this "
"plan containing at least one Stripe slot, for example "
f'{{"{plan}": {{"base": {{"price": "price_...", "quantity": 1}}}}}}.'
f"{legacy_hint}"
)


Expand Down
4 changes: 4 additions & 0 deletions api/ee/src/models/shared_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ class Permission(str, Enum):
VIEW_EVALUATION_QUEUES = "view_evaluation_queues"
EDIT_EVALUATION_QUEUES = "edit_evaluation_queues"

# Events
VIEW_EVENTS = "view_events"

# Tools
VIEW_TOOLS = "view_tools"
EDIT_TOOLS = "edit_tools"
Expand Down Expand Up @@ -228,6 +231,7 @@ def default_permissions(cls, role):
cls.EDIT_ENVIRONMENTS,
cls.EDIT_APP_ENVIRONMENT_DEPLOYMENT,
cls.CREATE_APP_ENVIRONMENT_DEPLOYMENT,
cls.VIEW_EVENTS,
]
ADMIN_PERMISSIONS = DEVELOPER_PERMISSIONS + [
cls.EDIT_WORKSPACE,
Expand Down
24 changes: 15 additions & 9 deletions api/ee/src/services/db_manager_ee.py
Original file line number Diff line number Diff line change
Expand Up @@ -886,17 +886,14 @@ async def add_user_to_workspace_and_org(
async def remove_user_from_workspace(
workspace_id: str,
email: str,
) -> WorkspaceResponse:
) -> bool:
"""
Remove a user from a workspace.

Args:
workspace_id (str): The ID of the workspace.
payload (UserRole): The payload containing the user email and role to remove.

Returns:
workspace (WorkspaceResponse): The updated workspace.

Raises:
HTTPException -- 403, from fastapi import Request
"""
Expand Down Expand Up @@ -995,8 +992,6 @@ async def remove_user_from_workspace(
membership_id=member_joined_org.id,
)

await session.commit()

# If there's an invitation for the provided email address, delete it
user_workspace_invitations_query = await session.execute(
select(InvitationDB)
Expand All @@ -1008,15 +1003,26 @@ async def remove_user_from_workspace(
load_only(
InvitationDB.id, # type: ignore
InvitationDB.project_id, # type: ignore
InvitationDB.user_id, # type: ignore
)
)
)
user_invitations = user_workspace_invitations_query.scalars().all()
for invitation in user_invitations:
await delete_invitation(str(invitation.id))
await session.delete(invitation)

workspace_db = await db_manager.get_workspace(workspace_id=workspace_id)
return await get_workspace_in_format(workspace_db)
log.info(
"[scopes] invitation deleted",
organization_id=str(workspace.organization_id),
workspace_id=str(workspace_id),
project_id=str(invitation.project_id),
user_id=str(invitation.user_id) if invitation.user_id else None,
membership_id=invitation.id,
)

await session.commit()

return True


async def create_organization(
Expand Down
10 changes: 9 additions & 1 deletion api/ee/src/services/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,12 @@ async def get_org_default_workspace(organization: Organization) -> WorkspaceDB:
)
)
workspace = result.scalars().first()
return workspace
if workspace is not None:
return workspace

result = await session.execute(
select(WorkspaceDB).filter_by(
organization_id=organization.id,
)
)
return result.scalars().first()
4 changes: 2 additions & 2 deletions api/ee/src/services/workspace_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ async def accept_workspace_invitation(
async def remove_user_from_workspace(
workspace_id: str,
email: str,
) -> WorkspaceResponse:
) -> bool:
"""
Remove a user from a workspace.

Expand All @@ -426,7 +426,7 @@ async def remove_user_from_workspace(
payload (UserRole): The payload containing the user ID and role to remove.

Returns:
WorkspaceResponse: The updated workspace.
bool: True when the member or pending invitation was removed.
"""

remove_user = await db_manager_ee.remove_user_from_workspace(workspace_id, email)
Expand Down
102 changes: 102 additions & 0 deletions api/ee/tests/pytest/acceptance/workspaces/test_workspace_members.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from uuid import uuid4


def _create_account(admin_api, *, email):
resp = admin_api(
"POST",
"/admin/simple/accounts/",
json={
"accounts": {
"u": {
"user": {"email": email},
"options": {
"create_api_keys": True,
"return_api_keys": True,
"seed_defaults": True,
},
}
}
},
)
assert resp.status_code == 200, resp.text
return resp.json()["accounts"]["u"]


def _delete_account_by_email(admin_api, *, email):
resp = admin_api(
"DELETE",
"/admin/simple/accounts/",
json={"accounts": {"u": {"user": {"email": email}}}, "confirm": "delete"},
)
assert resp.status_code == 204, resp.text


def _first_id(values):
return next(iter(values.values()))["id"]


def _api_key(account):
return account["api_keys"]["key"]


def _member_emails(org_details):
return {
member["user"]["email"]
for member in org_details["default_workspace"].get("members", [])
}


class TestWorkspaceMembers:
def test_remove_pending_workspace_invitation(self, admin_api):
uid = uuid4().hex[:12]
owner_email = f"owner-{uid}@test.agenta.ai"
invite_email = f"pending-{uid}@agenta.ai"

account = _create_account(admin_api, email=owner_email)
organization_id = _first_id(account["organizations"])
workspace_id = _first_id(account["workspaces"])
project_id = _first_id(account["projects"])
headers = {"Authorization": f"ApiKey {_api_key(account)}"}

try:
invite_resp = admin_api(
"POST",
f"/organizations/{organization_id}/workspaces/{workspace_id}/invite",
params={"project_id": project_id},
headers=headers,
json=[{"email": invite_email, "roles": ["viewer"]}],
)
assert invite_resp.status_code == 200, invite_resp.text

org_resp = admin_api(
"GET",
f"/organizations/{organization_id}",
params={"project_id": project_id},
headers=headers,
)
assert org_resp.status_code == 200, org_resp.text
assert invite_email in _member_emails(org_resp.json())

remove_resp = admin_api(
"DELETE",
f"/workspaces/{workspace_id}/users",
params={
"project_id": project_id,
"organization_id": organization_id,
"email": invite_email,
},
headers=headers,
)
assert remove_resp.status_code == 200, remove_resp.text
assert remove_resp.json() is True

refreshed_resp = admin_api(
"GET",
f"/organizations/{organization_id}",
params={"project_id": project_id},
headers=headers,
)
assert refreshed_resp.status_code == 200, refreshed_resp.text
assert invite_email not in _member_emails(refreshed_resp.json())
finally:
_delete_account_by_email(admin_api, email=owner_email)
Loading
Loading