Skip to content

Commit 1ea4e10

Browse files
🎨 introducing fogbugz client ⚠️ (#8258)
1 parent 8291d93 commit 1ea4e10

File tree

13 files changed

+436
-0
lines changed

13 files changed

+436
-0
lines changed

.env-devel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ DYNAMIC_SCHEDULER_UI_STORAGE_SECRET=adminadmin
141141
FUNCTION_SERVICES_AUTHORS='{"UN": {"name": "Unknown", "email": "[email protected]", "affiliation": "unknown"}}'
142142

143143
WEBSERVER_LICENSES={}
144+
WEBSERVER_FOGBUGZ={}
144145
LICENSES_ITIS_VIP_SYNCER_ENABLED=false
145146
LICENSES_ITIS_VIP_SYNCER_PERIODICITY=1D00:00:00
146147
LICENSES_ITIS_VIP_API_URL=https://replace-with-itis-api/{category}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""add support fogbugz fields
2+
3+
Revision ID: ec4f62595e0c
4+
Revises: b566f1b29012
5+
Create Date: 2025-08-26 13:06:10.879081+00:00
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "ec4f62595e0c"
14+
down_revision = "b566f1b29012"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.add_column(
22+
"products",
23+
sa.Column("support_assigned_fogbugz_person_id", sa.BigInteger(), nullable=True),
24+
)
25+
op.add_column(
26+
"products",
27+
sa.Column(
28+
"support_assigned_fogbugz_project_id", sa.BigInteger(), nullable=True
29+
),
30+
)
31+
# ### end Alembic commands ###
32+
33+
34+
def downgrade():
35+
# ### commands auto generated by Alembic - please adjust! ###
36+
op.drop_column("products", "support_assigned_fogbugz_project_id")
37+
op.drop_column("products", "support_assigned_fogbugz_person_id")
38+
# ### end Alembic commands ###

packages/postgres-database/src/simcore_postgres_database/models/products.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,5 +282,19 @@ class ProductLoginSettingsDict(TypedDict, total=False):
282282
nullable=True,
283283
doc="Group associated to this product support",
284284
),
285+
sa.Column(
286+
"support_assigned_fogbugz_person_id",
287+
sa.BigInteger,
288+
unique=False,
289+
nullable=True,
290+
doc="Fogbugz person ID to assign support case",
291+
),
292+
sa.Column(
293+
"support_assigned_fogbugz_project_id",
294+
sa.BigInteger,
295+
unique=False,
296+
nullable=True,
297+
doc="Fogbugz project ID to assign support case",
298+
),
285299
sa.PrimaryKeyConstraint("name", name="products_pk"),
286300
)

services/docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,7 @@ services:
763763
INVITATIONS_USERNAME: ${INVITATIONS_USERNAME}
764764

765765
WEBSERVER_LICENSES: ${WEBSERVER_LICENSES}
766+
WEBSERVER_FOGBUGZ: ${WEBSERVER_FOGBUGZ}
766767
LICENSES_ITIS_VIP_SYNCER_ENABLED: ${LICENSES_ITIS_VIP_SYNCER_ENABLED}
767768
LICENSES_ITIS_VIP_SYNCER_PERIODICITY: ${LICENSES_ITIS_VIP_SYNCER_PERIODICITY}
768769
LICENSES_ITIS_VIP_API_URL: ${LICENSES_ITIS_VIP_API_URL}
@@ -986,6 +987,7 @@ services:
986987
WEBSERVER_GROUPS: ${WB_DB_EL_GROUPS}
987988
WEBSERVER_INVITATIONS: ${WB_DB_EL_INVITATIONS}
988989
WEBSERVER_LICENSES: "null"
990+
WEBSERVER_FOGBUGZ: "null"
989991
WEBSERVER_LOGIN: ${WB_DB_EL_LOGIN}
990992
WEBSERVER_PAYMENTS: ${WB_DB_EL_PAYMENTS}
991993
WEBSERVER_NOTIFICATIONS: ${WB_DB_EL_NOTIFICATIONS}
@@ -1104,6 +1106,7 @@ services:
11041106
WEBSERVER_HOST: ${WEBSERVER_HOST}
11051107
WEBSERVER_INVITATIONS: ${WB_GC_INVITATIONS}
11061108
WEBSERVER_LICENSES: "null"
1109+
WEBSERVER_FOGBUGZ: "null"
11071110
WEBSERVER_LOGIN: ${WB_GC_LOGIN}
11081111
WEBSERVER_LOGLEVEL: ${WB_GC_LOGLEVEL}
11091112
WEBSERVER_NOTIFICATIONS: ${WB_GC_NOTIFICATIONS}
@@ -1183,6 +1186,7 @@ services:
11831186
WEBSERVER_GROUPS: 0
11841187
WEBSERVER_INVITATIONS: "null"
11851188
WEBSERVER_LICENSES: "null"
1189+
WEBSERVER_FOGBUGZ: "null"
11861190
WEBSERVER_LOGIN: "null"
11871191
WEBSERVER_NOTIFICATIONS: 0
11881192
WEBSERVER_PAYMENTS: "null"

services/web/server/src/simcore_service_webserver/application.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from .dynamic_scheduler.plugin import setup_dynamic_scheduler
3333
from .email.plugin import setup_email
3434
from .exporter.plugin import setup_exporter
35+
from .fogbugz.plugin import setup_fogbugz
3536
from .folders.plugin import setup_folders
3637
from .functions.plugin import setup_functions
3738
from .garbage_collector.plugin import setup_garbage_collector
@@ -166,6 +167,7 @@ def create_application() -> web.Application:
166167

167168
# conversations
168169
setup_conversations(app)
170+
setup_fogbugz(app) # Needed for support conversations
169171

170172
# licenses
171173
setup_licenses(app)

services/web/server/src/simcore_service_webserver/application_settings.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from .director_v2.settings import DirectorV2Settings
3737
from .dynamic_scheduler.settings import DynamicSchedulerSettings
3838
from .exporter.settings import ExporterSettings
39+
from .fogbugz.settings import FogbugzSettings
3940
from .garbage_collector.settings import GarbageCollectorSettings
4041
from .invitations.settings import InvitationsSettings
4142
from .licenses.settings import LicensesSettings
@@ -235,6 +236,14 @@ class ApplicationSettings(BaseApplicationSettings, MixinLoggingSettings):
235236
description="exporter plugin",
236237
),
237238
]
239+
240+
WEBSERVER_FOGBUGZ: Annotated[
241+
FogbugzSettings | None,
242+
Field(
243+
json_schema_extra={"auto_default_from_env": True},
244+
),
245+
]
246+
238247
WEBSERVER_GARBAGE_COLLECTOR: Annotated[
239248
GarbageCollectorSettings | None,
240249
Field(

services/web/server/src/simcore_service_webserver/fogbugz/__init__.py

Whitespace-only changes.
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""Interface to communicate with Fogbugz API
2+
3+
- Simple client to create cases in Fogbugz
4+
"""
5+
6+
import json
7+
import logging
8+
from typing import Any
9+
from urllib.parse import urljoin
10+
11+
import httpx
12+
from aiohttp import web
13+
from pydantic import AnyUrl, BaseModel, Field, SecretStr
14+
15+
from ..products import products_service
16+
from ..products.models import Product
17+
from .settings import get_plugin_settings
18+
19+
_logger = logging.getLogger(__name__)
20+
21+
_JSON_CONTENT_TYPE = "application/json"
22+
_UNKNOWN_ERROR_MESSAGE = "Unknown error occurred"
23+
24+
_FOGBUGZ_TIMEOUT: float = Field(
25+
default=45.0, description="API request timeout in seconds"
26+
)
27+
28+
29+
class FogbugzCaseCreate(BaseModel):
30+
fogbugz_project_id: str = Field(description="Project ID in Fogbugz")
31+
title: str = Field(description="Case title")
32+
description: str = Field(description="Case description/first comment")
33+
34+
35+
class FogbugzRestClient:
36+
"""REST client for Fogbugz API"""
37+
38+
def __init__(self, api_token: SecretStr, base_url: AnyUrl) -> None:
39+
self._client = httpx.AsyncClient(timeout=_FOGBUGZ_TIMEOUT)
40+
self._api_token = api_token
41+
self._base_url = base_url
42+
43+
async def _make_api_request(self, json_payload: dict[str, Any]) -> dict[str, Any]:
44+
"""Make a request to Fogbugz API with common formatting"""
45+
# Fogbugz requires multipart/form-data with stringified JSON
46+
files = {"request": (None, json.dumps(json_payload), _JSON_CONTENT_TYPE)}
47+
48+
url = urljoin(f"{self._base_url}", "f/api/0/jsonapi")
49+
50+
response = await self._client.post(url, files=files)
51+
response.raise_for_status()
52+
response_data: dict[str, Any] = response.json()
53+
return response_data
54+
55+
async def create_case(self, data: FogbugzCaseCreate) -> str:
56+
"""Create a new case in Fogbugz"""
57+
json_payload = {
58+
"cmd": "new",
59+
"token": self._api_token.get_secret_value(),
60+
"ixProject": data.fogbugz_project_id,
61+
"sTitle": data.title,
62+
"sEvent": data.description,
63+
}
64+
65+
response_data = await self._make_api_request(json_payload)
66+
67+
# Fogbugz API returns case ID in the response
68+
case_id = response_data.get("data", {}).get("case", {}).get("ixBug", None)
69+
if case_id is None:
70+
msg = "Failed to create case in Fogbugz"
71+
raise ValueError(msg)
72+
73+
return str(case_id)
74+
75+
async def resolve_case(self, case_id: str) -> None:
76+
"""Resolve a case in Fogbugz"""
77+
json_payload = {
78+
"cmd": "resolve",
79+
"token": self._api_token.get_secret_value(),
80+
"ixBug": case_id,
81+
}
82+
83+
response_data = await self._make_api_request(json_payload)
84+
85+
# Check if the operation was successful
86+
if response_data.get("error"):
87+
error_msg = response_data.get("error", _UNKNOWN_ERROR_MESSAGE)
88+
msg = f"Failed to resolve case in Fogbugz: {error_msg}"
89+
raise ValueError(msg)
90+
91+
async def get_case_status(self, case_id: str) -> str:
92+
"""Get the status of a case in Fogbugz"""
93+
json_payload = {
94+
"cmd": "search",
95+
"token": self._api_token.get_secret_value(),
96+
"q": case_id,
97+
"cols": "sStatus",
98+
}
99+
100+
response_data = await self._make_api_request(json_payload)
101+
102+
# Check if the operation was successful
103+
if response_data.get("error"):
104+
error_msg = response_data.get("error", _UNKNOWN_ERROR_MESSAGE)
105+
msg = f"Failed to get case status from Fogbugz: {error_msg}"
106+
raise ValueError(msg)
107+
108+
# Extract the status from the search results
109+
cases = response_data.get("data", {}).get("cases", [])
110+
if not cases:
111+
msg = f"Case {case_id} not found in Fogbugz"
112+
raise ValueError(msg)
113+
114+
# Find the case with matching ixBug
115+
target_case = None
116+
for case in cases:
117+
if str(case.get("ixBug")) == str(case_id):
118+
target_case = case
119+
break
120+
121+
if target_case is None:
122+
msg = f"Case {case_id} not found in search results"
123+
raise ValueError(msg)
124+
125+
# Get the status from the found case
126+
status: str = target_case.get("sStatus", "")
127+
if not status:
128+
msg = f"Status not found for case {case_id}"
129+
raise ValueError(msg)
130+
131+
return status
132+
133+
async def reopen_case(self, case_id: str, assigned_fogbugz_person_id: str) -> None:
134+
"""Reopen a case in Fogbugz (uses reactivate for resolved cases, reopen for closed cases)"""
135+
# First get the current status to determine which command to use
136+
current_status = await self.get_case_status(case_id)
137+
138+
# Determine the command based on current status
139+
if current_status.lower().startswith("resolved"):
140+
cmd = "reactivate"
141+
elif current_status.lower().startswith("closed"):
142+
cmd = "reopen"
143+
else:
144+
msg = f"Cannot reopen case {case_id} with status '{current_status}'. Only resolved or closed cases can be reopened."
145+
raise ValueError(msg)
146+
147+
json_payload = {
148+
"cmd": cmd,
149+
"token": self._api_token.get_secret_value(),
150+
"ixBug": case_id,
151+
"ixPersonAssignedTo": assigned_fogbugz_person_id,
152+
}
153+
154+
response_data = await self._make_api_request(json_payload)
155+
156+
# Check if the operation was successful
157+
if response_data.get("error"):
158+
error_msg = response_data.get("error", _UNKNOWN_ERROR_MESSAGE)
159+
msg = f"Failed to reopen case in Fogbugz: {error_msg}"
160+
raise ValueError(msg)
161+
162+
163+
_APP_KEY = f"{__name__}.{FogbugzRestClient.__name__}"
164+
165+
166+
async def setup_fogbugz_rest_client(app: web.Application) -> None:
167+
"""Setup Fogbugz REST client"""
168+
settings = get_plugin_settings(app)
169+
170+
# Fail fast if unexpected configuration
171+
products: list[Product] = products_service.list_products(app=app)
172+
for product in products:
173+
if product.support_standard_group_id is not None:
174+
if product.support_assigned_fogbugz_person_id is None:
175+
msg = (
176+
f"Product '{product.name}' has support_standard_group_id set "
177+
"but `support_assigned_fogbugz_person_id` is not configured."
178+
)
179+
raise ValueError(msg)
180+
if product.support_assigned_fogbugz_project_id is None:
181+
msg = (
182+
f"Product '{product.name}' has support_standard_group_id set "
183+
"but `support_assigned_fogbugz_project_id` is not configured."
184+
)
185+
raise ValueError(msg)
186+
else:
187+
_logger.info(
188+
"Product '%s' has support conversation disabled (therefore Fogbugz integration is not necessary for this product)",
189+
product.name,
190+
)
191+
192+
client = FogbugzRestClient(
193+
api_token=settings.FOGBUGZ_API_TOKEN,
194+
base_url=settings.FOGBUGZ_URL,
195+
)
196+
197+
app[_APP_KEY] = client
198+
199+
200+
def get_fogbugz_rest_client(app: web.Application) -> FogbugzRestClient:
201+
"""Get Fogbugz REST client from app state"""
202+
app_key: FogbugzRestClient = app[_APP_KEY]
203+
return app_key
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""tags management subsystem"""
2+
3+
import logging
4+
5+
from aiohttp import web
6+
from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup
7+
8+
from ..products.plugin import setup_products
9+
from ._client import setup_fogbugz_rest_client
10+
11+
_logger = logging.getLogger(__name__)
12+
13+
14+
@app_module_setup(
15+
__name__,
16+
ModuleCategory.ADDON,
17+
settings_name="WEBSERVER_FOGBUGZ",
18+
logger=_logger,
19+
)
20+
def setup_fogbugz(app: web.Application):
21+
setup_products(app)
22+
app.on_startup.append(setup_fogbugz_rest_client)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from aiohttp import web
2+
from pydantic import AnyUrl, SecretStr
3+
from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY
4+
from settings_library.base import BaseCustomSettings
5+
6+
7+
class FogbugzSettings(BaseCustomSettings):
8+
FOGBUGZ_API_TOKEN: SecretStr
9+
FOGBUGZ_URL: AnyUrl
10+
11+
12+
def get_plugin_settings(app: web.Application) -> FogbugzSettings:
13+
settings = app[APP_SETTINGS_KEY].WEBSERVER_FOGBUGZ
14+
assert settings, "plugin.setup_fogbugz not called?" # nosec
15+
assert isinstance(settings, FogbugzSettings) # nosec
16+
return settings

0 commit comments

Comments
 (0)