Skip to content

Commit 8eeba8c

Browse files
authored
feat(ideascale): Update fields for F11 params, saves JSON artifacts | NPG-000 (#661)
# Description Updates ideascale importer to use F11. Saves JSON artifacts to output directory. ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? - [x] Run `ideascale-importer ideascale import-all` for single network - [x] Run `ideascale-importer ideascale import-all` for `mainnet` and `preprod` ## Checklist - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
1 parent adb2c1c commit 8eeba8c

File tree

10 files changed

+410
-181
lines changed

10 files changed

+410
-181
lines changed

utilities/ideascale-importer/ideascale_importer/cli/ideascale.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""IdeaScale CLI commands."""
22

33
import asyncio
4-
from typing import Optional, List
4+
from pathlib import Path
5+
from typing import Optional
56
import typer
67

78
from ideascale_importer.ideascale.client import Client
@@ -39,6 +40,9 @@ def import_all(
3940
envvar="IDEASCALE_API_URL",
4041
help="IdeaScale API URL",
4142
),
43+
output_dir: Optional[str] = typer.Option(
44+
default=None, envvar="IDEASCALE_OUTPUT_DIR", help="Output directory for generated files"
45+
),
4246
):
4347
"""Import all event data from IdeaScale for a given event."""
4448
configure_logger(log_level, log_format)
@@ -47,13 +51,23 @@ async def inner(
4751
event_id: int,
4852
proposals_scores_csv_path: Optional[str],
4953
ideascale_api_url: str,
54+
output_dir: Optional[str]
5055
):
56+
# check if output_dir path exists, or create otherwise
57+
if output_dir is None:
58+
logger.info("No output directory was defined.")
59+
else:
60+
output_dir = Path(output_dir)
61+
output_dir.mkdir(exist_ok=True, parents=True)
62+
logger.info(f"Output directory for artifacts: {output_dir}")
63+
5164
importer = Importer(
5265
api_token,
5366
database_url,
5467
event_id,
5568
proposals_scores_csv_path,
5669
ideascale_api_url,
70+
output_dir
5771
)
5872

5973
try:
@@ -63,4 +77,4 @@ async def inner(
6377
except Exception as e:
6478
logger.error(e)
6579

66-
asyncio.run(inner(event_id, proposals_scores_csv, ideascale_api_url))
80+
asyncio.run(inner(event_id, proposals_scores_csv, ideascale_api_url, output_dir))

utilities/ideascale-importer/ideascale_importer/cli/snapshot.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def import_snapshot(
2525
catalyst_toolbox_path: str = typer.Option(
2626
default="catalyst-toolbox", envvar="CATALYST_TOOLBOX_PATH", help="Path to the catalyst-toolbox"
2727
),
28-
gvc_api_url: str = typer.Option(..., envvar="GVC_API_URL", help="URL of the GVC API"),
28+
gvc_api_url: str = typer.Option(default="", envvar="GVC_API_URL", help="DEPRECATED. URL of the GVC API"),
2929
raw_snapshot_file: str = typer.Option(
3030
None,
3131
help=(
@@ -102,7 +102,6 @@ async def inner():
102102
network_ids=network_ids,
103103
snapshot_tool_path=snapshot_tool_path,
104104
catalyst_toolbox_path=catalyst_toolbox_path,
105-
gvc_api_url=gvc_api_url,
106105
raw_snapshot_file=raw_snapshot_file,
107106
ssh_config=ssh_config,
108107
)

utilities/ideascale-importer/ideascale_importer/db/__init__.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ async def insert(conn: asyncpg.Connection, model: Model) -> Any:
6464
return ret[0]
6565
return None
6666

67+
6768
async def select(conn: asyncpg.Connection, model: Model, cond: Dict[str, str] = {}) -> List[Any]:
6869
"""Select a single model."""
6970

@@ -77,7 +78,7 @@ async def select(conn: asyncpg.Connection, model: Model, cond: Dict[str, str] =
7778
SELECT {cols_str}
7879
FROM {model.table()}
7980
{f' WHERE {cond_str}' if cond_str else ' '}
80-
""".strip()
81+
""".strip()
8182

8283
result = await conn.fetch(stmt_template)
8384

@@ -123,9 +124,13 @@ async def upsert_many(
123124
pre_update_set_str = ",".join([f"{col} = {val}" for col, val in pre_update_cols.items()])
124125
pre_update_cond_str = " ".join([f"{col} {cond}" for col, cond in pre_update_cond.items()])
125126

126-
pre_update_template = f"""
127+
pre_update_template = (
128+
f"""
127129
WITH updated AS ({ f"UPDATE {models[0].table()} SET {pre_update_set_str} {f' WHERE {pre_update_cond_str}' if pre_update_cond_str else ' '}" })
128-
""".strip() if pre_update_set_str else " "
130+
""".strip()
131+
if pre_update_set_str
132+
else " "
133+
)
129134

130135
stmt_template = f"""
131136
{pre_update_template}
@@ -172,6 +177,27 @@ async def event_exists(conn: asyncpg.Connection, id: int) -> bool:
172177
return row is not None
173178

174179

180+
class EventThesholdNotFound(Exception):
181+
"""Raised when the event's voting power threshold is not found."""
182+
183+
...
184+
185+
186+
async def event_threshold(conn: asyncpg.Connection, row_id: int) -> int:
187+
"""Fetch the event's voting power threshold in ADA."""
188+
res = await conn.fetchrow("SELECT voting_power_threshold FROM event WHERE row_id = $1", row_id)
189+
if res is None:
190+
raise EventThesholdNotFound()
191+
threshold = int(res["voting_power_threshold"]/1000000)
192+
return threshold
193+
194+
async def update_event_description(conn: asyncpg.Connection, row_id: int, description: str):
195+
"""Update the event description.
196+
197+
NOTE: this field includes a JSON string used to inform other services."""
198+
await conn.execute(f"UPDATE event SET description = '{description}' WHERE row_id = $1", row_id)
199+
200+
175201
class VoteOptionsNotFound(Exception):
176202
"""Raised when a vote option is not found."""
177203

utilities/ideascale-importer/ideascale_importer/db/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ def table() -> str:
219219
"""Return the name of the table that this model is stored in."""
220220
return "snapshot"
221221

222+
222223
@dataclass
223224
class Config(Model):
224225
"""Represents a database config."""
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from typing import Optional
2+
from ideascale_importer.db.models import Objective, Proposal
3+
from pydantic import BaseModel
4+
5+
6+
class ProposalJson(BaseModel):
7+
"""A proposal in JSON used for output artifacts."""
8+
9+
category_name: str
10+
chain_vote_options: str
11+
challenge_id: str
12+
challenge_type: str
13+
chain_vote_type: str
14+
internal_id: str
15+
proposal_funds: str
16+
proposal_id: str
17+
proposal_impact_score: str
18+
proposal_summary: str
19+
proposal_title: str
20+
proposal_url: str
21+
proposer_email: Optional[str] = None
22+
proposer_name: Optional[str] = None
23+
proposer_relevant_experience: Optional[str] = None
24+
proposer_url: Optional[str] = None
25+
proposal_solution: Optional[str] = None
26+
files_url: str
27+
28+
29+
class ChallengesJson(BaseModel):
30+
id: str
31+
internal_id: int
32+
title: str
33+
challenge_type: str
34+
challenge_url: str
35+
description: str
36+
fund_id: str
37+
rewards_total: str
38+
proposers_rewards: str
39+
40+
41+
def objective_to_challenge_json(obj: Objective, ideascale_url: str, idx: int = 0) -> ChallengesJson:
42+
c_url = f"{ideascale_url}/c/campaigns/{obj.id}/"
43+
return ChallengesJson.model_validate(
44+
{
45+
"id": f"{idx}",
46+
"internal_id": obj.id,
47+
"title": obj.title,
48+
"challenge_type": obj.category.removeprefix("catalyst-"),
49+
"challenge_url": c_url,
50+
"description": obj.description,
51+
"fund_id": f"{obj.event}",
52+
"rewards_total": f"{obj.rewards_total}",
53+
"proposers_rewards": f"{obj.proposers_rewards}",
54+
}
55+
)
56+
57+
58+
def json_from_proposal(prop: Proposal, challenge: ChallengesJson, fund_id: int, idx: int = 0) -> ProposalJson:
59+
if prop.proposer_relevant_experience == "":
60+
experience = None
61+
else:
62+
experience = prop.proposer_relevant_experience
63+
if prop.extra is not None:
64+
solution = prop.extra.get("solution", None)
65+
else:
66+
solution = None
67+
return ProposalJson.model_validate(
68+
{
69+
"category_name": f"Fund {fund_id}",
70+
"chain_vote_options": "blank,yes,no",
71+
"challenge_id": challenge.id,
72+
"challenge_type": challenge.challenge_type,
73+
"chain_vote_type": "private",
74+
"internal_id": f"{idx}",
75+
"proposal_funds": f"{prop.funds}",
76+
"proposal_id": f"{prop.id}",
77+
"proposal_impact_score": f"{prop.impact_score}",
78+
"proposal_summary": prop.summary,
79+
"proposal_title": prop.title,
80+
"proposal_url": prop.url,
81+
"proposer_name": prop.proposer_name,
82+
"proposer_relevant_experience": experience,
83+
"proposal_solution": solution,
84+
"files_url": prop.files_url,
85+
}
86+
)
87+
88+
89+
class FundsJson(BaseModel):
90+
"""Current Fund (Event) information in JSON used for output artifacts."""
91+
id: int
92+
goal: str
93+
threshold: int
94+
rewards_info: str = ""

utilities/ideascale-importer/ideascale_importer/ideascale/client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,16 @@ async def funnel(self, funnel_id: int) -> Funnel:
209209
res = await self._get(f"/a/rest/v1/funnels/{funnel_id}")
210210
return Funnel.model_validate(res)
211211

212+
async def event_themes(self, campaign_id: int, themes_custom_key: str) -> List[str]:
213+
"""Get the list of themes for this Fund,by IdeaScale `campaign_id`."""
214+
try:
215+
res = await self._get(f"/a/rest/v1/customFields/idea/campaigns/{campaign_id}")
216+
themes_fields = [f for f in res if f["key"] and f["key"] == themes_custom_key]
217+
themes = themes_fields[0]["options"].split("\r\n")
218+
return themes
219+
except Exception as e:
220+
raise Exception(f"Unable to fetch themes: {e}")
221+
212222
async def _get(self, path: str) -> Mapping[str, Any] | Iterable[Mapping[str, Any]]:
213223
"""Execute a GET request on IdeaScale API."""
214224
headers = {"api_token": self.api_token}

0 commit comments

Comments
 (0)