Skip to content

Commit 3510d2f

Browse files
introducing fogbugz client
1 parent 0bbcd00 commit 3510d2f

File tree

10 files changed

+382
-0
lines changed

10 files changed

+382
-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}

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: 10 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,15 @@ 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+
# NOTE: `bool` is to keep backwards compatibility
245+
),
246+
]
247+
238248
WEBSERVER_GARBAGE_COLLECTOR: Annotated[
239249
GarbageCollectorSettings | None,
240250
Field(

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

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

services/web/server/src/simcore_service_webserver/products/_models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ class Product(BaseModel):
143143
support_standard_group_id: Annotated[
144144
int | None, Field(description="Support standard group ID, None if disabled")
145145
] = None
146+
support_assigned_fogbugz_person_id: Annotated[
147+
int | None,
148+
Field(description="Support assigned Fogbugz person ID, None if disabled"),
149+
] = None
150+
support_assigned_fogbugz_project_id: Annotated[
151+
int | None,
152+
Field(description="Support assigned Fogbugz project ID, None if disabled"),
153+
] = None
146154

147155
is_payment_enabled: Annotated[
148156
bool,

0 commit comments

Comments
 (0)