revnext 0.1.0__py3-none-any.whl

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.
revnext/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """
2
+ Revolution Next (*.revolutionnext.com.au) report downloads via REST API.
3
+ """
4
+
5
+ from revnext.config import RevNextConfig, get_revnext_base_url_from_env
6
+ from revnext.parts_by_bin_report import download_parts_by_bin_report
7
+ from revnext.parts_price_list_report import download_parts_price_list_report
8
+ from revnext.download_all_reports import download_all_reports
9
+
10
+ __all__ = [
11
+ "RevNextConfig",
12
+ "get_revnext_base_url_from_env",
13
+ "download_parts_by_bin_report",
14
+ "download_parts_price_list_report",
15
+ "download_all_reports",
16
+ ]
17
+
18
+ __version__ = "0.1.0"
revnext/common.py ADDED
@@ -0,0 +1,196 @@
1
+ """
2
+ Shared utilities for Revolution Next (*.revolutionnext.com.au) report downloads.
3
+ Cookie loading, session creation, and generic submit → poll → loadData → download flow.
4
+ """
5
+
6
+ import json
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Callable
10
+ from urllib.parse import urlparse
11
+
12
+ import requests
13
+
14
+
15
+ def _common_headers(base_url: str) -> dict:
16
+ """Build common request headers for the given base URL."""
17
+ return {
18
+ "accept": "*/*",
19
+ "accept-language": "en-AU,en-US;q=0.9,en-GB;q=0.8,en;q=0.7",
20
+ "content-type": "application/json; charset=UTF-8",
21
+ "origin": base_url,
22
+ "referrer": f"{base_url}/next/Fluid.html?useTabs",
23
+ "sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"',
24
+ "sec-ch-ua-mobile": "?0",
25
+ "sec-ch-ua-platform": '"Windows"',
26
+ "sec-fetch-dest": "empty",
27
+ "sec-fetch-mode": "cors",
28
+ "sec-fetch-site": "same-origin",
29
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
30
+ }
31
+
32
+
33
+ def load_cookies_for_domain(cookies_path: Path, domain: str) -> list[tuple[str, str]]:
34
+ """Load cookies from Chrome-export JSON and return (name, value) pairs for the domain."""
35
+ with open(cookies_path, encoding="utf-8") as f:
36
+ raw = json.load(f)
37
+ pairs = []
38
+ for c in raw:
39
+ d = c.get("domain", "")
40
+ if not d:
41
+ continue
42
+ if domain == d or domain.endswith(d.lstrip(".")):
43
+ pairs.append((c["name"], c["value"]))
44
+ return pairs
45
+
46
+
47
+ def cookie_header(pairs: list[tuple[str, str]]) -> str:
48
+ """Build Cookie header value from (name, value) pairs."""
49
+ return "; ".join(f"{name}={value}" for name, value in pairs)
50
+
51
+
52
+ def create_session(
53
+ cookies_path: Path,
54
+ service_object: str,
55
+ base_url: str,
56
+ ) -> requests.Session:
57
+ """Create a requests Session with cookies and common headers for the given base URL and service object."""
58
+ parsed = urlparse(base_url)
59
+ domain = parsed.netloc or parsed.path
60
+ if not domain:
61
+ raise ValueError(f"Invalid base_url: {base_url}")
62
+ cookie_pairs = load_cookies_for_domain(cookies_path, domain)
63
+ if not cookie_pairs:
64
+ raise ValueError(
65
+ f"No cookies found for {domain}. Export cookies for this domain (e.g. revnext-cookies.json)."
66
+ )
67
+ session = requests.Session()
68
+ session.headers.update(_common_headers(base_url))
69
+ session.headers["cookie"] = cookie_header(cookie_pairs)
70
+ session.headers["x-service-object"] = service_object
71
+ return session
72
+
73
+
74
+ def extract_task_id(submit_response: dict) -> str | None:
75
+ """Extract taskID from submitActivityTask response."""
76
+ for ds in submit_response.get("dataSets", []):
77
+ if ds.get("name") != "dsActivityTask":
78
+ continue
79
+ d = ds.get("dataSet", {}).get("dsActivityTask", {})
80
+ for row in d.get("ttActivityTask", []):
81
+ tid = row.get("taskID")
82
+ if tid:
83
+ return tid
84
+ return None
85
+
86
+
87
+ def is_poll_done(poll_response: dict) -> bool:
88
+ """True when report is ready (autoPollResponse returns -1)."""
89
+ for cp in poll_response.get("ctrlProp", []):
90
+ if cp.get("name") == "button.autoPollResponse" and cp.get("value") == "-1":
91
+ return True
92
+ return False
93
+
94
+
95
+ def get_response_url_from_load_data(load_data: dict) -> str | None:
96
+ """Extract responseUrl from loadData response (ttActivityTaskResponse)."""
97
+ for ds in load_data.get("dataSets", []):
98
+ if ds.get("name") != "dsActivityTask":
99
+ continue
100
+ d = ds.get("dataSet", {}).get("dsActivityTask", {})
101
+ for row in d.get("ttActivityTaskResponse", []):
102
+ url = row.get("responseUrl")
103
+ if url:
104
+ return url
105
+ return None
106
+
107
+
108
+ def run_report_flow(
109
+ session: requests.Session,
110
+ service_object: str,
111
+ activity_tab_id: str,
112
+ get_submit_body: Callable[[], dict],
113
+ output_path: Path,
114
+ base_url: str,
115
+ post_submit_hook: Callable[[requests.Session], None] | None = None,
116
+ max_polls: int = 60,
117
+ poll_interval: float = 2,
118
+ ) -> Path:
119
+ """
120
+ Submit report task, poll until ready, loadData for download URL, then download CSV.
121
+ Optionally call post_submit_hook(session) after submit (e.g. onChoose_btn_closesubmit).
122
+ Returns the path where the file was saved.
123
+ """
124
+ submit_url = f"{base_url}/next/rest/si/static/submitActivityTask"
125
+ r = session.post(submit_url, json=get_submit_body())
126
+ r.raise_for_status()
127
+ submit_data = r.json()
128
+ if not submit_data.get("submittedSuccess"):
129
+ raise RuntimeError("submitActivityTask did not report success.")
130
+
131
+ task_id = extract_task_id(submit_data)
132
+ if not task_id:
133
+ raise RuntimeError("Could not get taskID from submit response.")
134
+ print(f"Task submitted: {task_id}")
135
+
136
+ if post_submit_hook:
137
+ post_submit_hook(session)
138
+
139
+ poll_url = f"{base_url}/next/rest/si/presenter/autoPollResponse"
140
+ poll_body = {
141
+ "_userContext_vg_coid": "03",
142
+ "_userContext_vg_divid": "1",
143
+ "_userContext_vg_dftdpt": "570",
144
+ "activityTabId": activity_tab_id,
145
+ "ctrlProp": [
146
+ {"name": "ttActivityTask.taskID", "prop": "SCREENVALUE", "value": task_id}
147
+ ],
148
+ "uiType": "ISC",
149
+ }
150
+ for i in range(max_polls):
151
+ time.sleep(poll_interval)
152
+ r = session.post(poll_url, json=poll_body)
153
+ r.raise_for_status()
154
+ poll_data = r.json()
155
+ if is_poll_done(poll_data):
156
+ print("Report generation complete.")
157
+ break
158
+ print(f" Poll {i + 1}: still generating...")
159
+ else:
160
+ raise RuntimeError("Timed out waiting for report.")
161
+
162
+ load_url = f"{base_url}/next/rest/si/static/loadData"
163
+ load_body = {
164
+ "taskID": task_id,
165
+ "ctrlProp": [{"name": "dummy", "prop": "LOADDATA", "value": "dummy"}],
166
+ "parentActivity": None,
167
+ "historyID": "self,dummy,dummy",
168
+ "tabID": "self",
169
+ "activityType": "dummy",
170
+ "fluidService": "dummy",
171
+ "uiType": "ISC",
172
+ "_userContext_vg_coid": "03",
173
+ "_userContext_vg_divid": "1",
174
+ "_userContext_vg_dftdpt": "570",
175
+ "activityTabId": activity_tab_id,
176
+ "loadMode": "EDIT",
177
+ "loadRowid": "dummy",
178
+ }
179
+ r = session.post(load_url, json=load_body)
180
+ r.raise_for_status()
181
+ load_data = r.json()
182
+
183
+ response_url = get_response_url_from_load_data(load_data)
184
+ if not response_url:
185
+ raise RuntimeError("Could not get responseUrl from loadData.")
186
+
187
+ if not response_url.startswith("http"):
188
+ response_url = f"{base_url}/next/{response_url.lstrip('/')}"
189
+ print(f"Download URL: {response_url}")
190
+
191
+ r = session.get(response_url)
192
+ r.raise_for_status()
193
+ output_path.parent.mkdir(parents=True, exist_ok=True)
194
+ output_path.write_bytes(r.content)
195
+ print(f"Saved: {output_path}")
196
+ return output_path
revnext/config.py ADDED
@@ -0,0 +1,61 @@
1
+ """
2
+ Configuration for Revolution Next (*.revolutionnext.com.au) report downloads.
3
+ """
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ def _load_dotenv_if_available() -> None:
12
+ try:
13
+ from dotenv import load_dotenv
14
+ load_dotenv()
15
+ except ImportError:
16
+ pass
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class RevNextConfig:
21
+ """Configuration for Revolution Next (*.revolutionnext.com.au) API / report downloads."""
22
+
23
+ base_url: str
24
+ cookies_path: Optional[Path] = None
25
+ username: Optional[str] = None
26
+ password: Optional[str] = None
27
+
28
+ @classmethod
29
+ def from_env(
30
+ cls,
31
+ *,
32
+ base_url: Optional[str] = None,
33
+ cookies_path: Optional[Path] = None,
34
+ username: Optional[str] = None,
35
+ password: Optional[str] = None,
36
+ load_dotenv: bool = True,
37
+ ) -> "RevNextConfig":
38
+ """Build config from environment variables. Override any field by passing it explicitly."""
39
+ if load_dotenv:
40
+ _load_dotenv_if_available()
41
+ url = base_url or os.getenv("REVOLUTIONNEXT_URL") or "https://mikecarney.revolutionnext.com.au"
42
+ if url and not url.startswith(("http://", "https://")):
43
+ url = "https://" + url
44
+ cp = cookies_path
45
+ if cp is None and os.getenv("REVOLUTIONNEXT_COOKIES_PATH"):
46
+ cp = Path(os.getenv("REVOLUTIONNEXT_COOKIES_PATH"))
47
+ return cls(
48
+ base_url=url,
49
+ cookies_path=cp,
50
+ username=username or os.getenv("REVOLUTIONNEXT_USERNAME"),
51
+ password=password or os.getenv("REVOLUTIONNEXT_PASSWORD"),
52
+ )
53
+
54
+
55
+ def get_revnext_base_url_from_env() -> str:
56
+ """Return RevNext base URL from environment (REVOLUTIONNEXT_URL)."""
57
+ _load_dotenv_if_available()
58
+ url = os.getenv("REVOLUTIONNEXT_URL") or "https://mikecarney.revolutionnext.com.au"
59
+ if not url.startswith(("http://", "https://")):
60
+ url = "https://" + url
61
+ return url
@@ -0,0 +1,49 @@
1
+ """
2
+ Download both Revolution Next CSV reports (Parts By Bin Location and Parts Price List)
3
+ using cookies and optional config.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from revnext.parts_by_bin_report import download_parts_by_bin_report
10
+ from revnext.parts_price_list_report import download_parts_price_list_report
11
+
12
+
13
+ def download_all_reports(
14
+ cookies_path: Optional[Path | str] = None,
15
+ output_dir: Optional[Path | str] = None,
16
+ base_url: Optional[str] = None,
17
+ ) -> list[Path]:
18
+ """
19
+ Run both Parts By Bin Location and Parts Price List reports and save CSVs.
20
+ Returns the list of paths where files were saved.
21
+
22
+ Args:
23
+ cookies_path: Path to cookies JSON. Defaults to current dir / revnext-cookies.json.
24
+ output_dir: Directory for output CSVs. Defaults to current directory.
25
+ base_url: Revolution Next base URL. Defaults to REVOLUTIONNEXT_URL env or default.
26
+ """
27
+ output_dir = Path(output_dir) if output_dir is not None else Path.cwd()
28
+
29
+ saved: list[Path] = []
30
+ print("--- Parts By Bin Location ---")
31
+ path1 = download_parts_by_bin_report(
32
+ cookies_path=cookies_path,
33
+ output_path=output_dir / "Parts_By_Bin_Location.csv",
34
+ base_url=base_url,
35
+ )
36
+ saved.append(path1)
37
+ print("\n--- Parts Price List ---")
38
+ path2 = download_parts_price_list_report(
39
+ cookies_path=cookies_path,
40
+ output_path=output_dir / "Parts_Price_List.csv",
41
+ base_url=base_url,
42
+ )
43
+ saved.append(path2)
44
+ print(f"\nDone. Saved {len(saved)} file(s).")
45
+ return saved
46
+
47
+
48
+ if __name__ == "__main__":
49
+ download_all_reports()
@@ -0,0 +1,250 @@
1
+ """
2
+ Download Parts By Bin Location CSV report from Revolution Next (*.revolutionnext.com.au).
3
+ Uses cookies (Chrome export format) and configurable base URL.
4
+ """
5
+
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from revnext.common import create_session, run_report_flow
11
+ from revnext.config import get_revnext_base_url_from_env
12
+
13
+ SERVICE_OBJECT = "Revolution.Activity.IM.RPT.PartsByBinLocationPR"
14
+ ACTIVITY_TAB_ID = "Nce9eac79_528b_4fc4_a294_b055a6dde16b"
15
+
16
+
17
+ def _build_submit_body() -> dict:
18
+ """Build the submitActivityTask request body. Use today's date for the trigger."""
19
+ tz = "+10:00"
20
+ now = datetime.now()
21
+ start_date = now.strftime("%Y-%m-%d")
22
+ start_time = f"{start_date}T{now.strftime('%H:%M')}:00.000{tz}"
23
+ return {
24
+ "_userContext_vg_coid": "03",
25
+ "_userContext_vg_divid": "1",
26
+ "_userContext_vg_dftdpt": "570",
27
+ "activityTabId": ACTIVITY_TAB_ID,
28
+ "dataSets": [
29
+ {
30
+ "name": "dsActivityTask",
31
+ "id": None,
32
+ "dataSet": {
33
+ "dsActivityTask": {
34
+ "prods:hasChanges": True,
35
+ "ttActivityTask": [
36
+ {
37
+ "prods:id": "ttActivityTask1827072",
38
+ "prods:rowState": "created",
39
+ "fldId": 1,
40
+ "taskID": "",
41
+ "loadRowidPassThrough": "dummy",
42
+ "executeActivityTaskNow": False,
43
+ "executedAt": None,
44
+ "logMessages": "",
45
+ "startTime": None,
46
+ "endTime": None,
47
+ }
48
+ ],
49
+ "prods:before": {},
50
+ }
51
+ },
52
+ },
53
+ {
54
+ "name": "dsActivityTaskTriggers",
55
+ "id": None,
56
+ "dataSet": {
57
+ "dsActivityTaskTriggers": {
58
+ "prods:hasChanges": True,
59
+ "ttActivityTaskTrigger": [
60
+ {
61
+ "prods:id": "ttActivityTaskTrigger1972480",
62
+ "prods:rowState": "created",
63
+ "fldId": 1,
64
+ "mode": "O",
65
+ "startDateTime": start_time,
66
+ "startDate": f"{start_date}T00:00:00.000{tz}",
67
+ "startHour": now.hour,
68
+ "startMinute": now.minute,
69
+ "recurEvery": 1,
70
+ "recurEveryUOM": "days",
71
+ "weeklySun": False,
72
+ "weeklyMon": False,
73
+ "weeklyTue": False,
74
+ "weeklyWed": False,
75
+ "weeklyThu": False,
76
+ "weeklyFri": False,
77
+ "weeklySat": False,
78
+ "monthsList": "",
79
+ "monthlyMode": "",
80
+ "monthlyDaysList": "",
81
+ "monthlyOnWeekNumber": "",
82
+ "monthlyOnDayOfWeek": "",
83
+ "triggerDescription": "",
84
+ "triggerNextSchedule": None,
85
+ "windowTimeFrom": "",
86
+ "windowTimeTo": "",
87
+ "windowAllDay": False,
88
+ }
89
+ ],
90
+ "prods:before": {},
91
+ }
92
+ },
93
+ },
94
+ {
95
+ "name": "dsParams",
96
+ "id": None,
97
+ "dataSet": {
98
+ "dsParams": {
99
+ "prods:hasChanges": True,
100
+ "tt_params": [
101
+ {
102
+ "prods:id": "tt_paramsFldId1",
103
+ "prods:rowState": "modified",
104
+ "fldid": 1,
105
+ "coid": "03",
106
+ "divid": "1",
107
+ "activityid": "IM.RPT.PartsByBinLocationPR",
108
+ "taskid": "",
109
+ "tasksts": None,
110
+ "rptid": "",
111
+ "pdf": "",
112
+ "formprt": False,
113
+ "csvout": True,
114
+ "emailopt": "n",
115
+ "emailme": False,
116
+ "useremail": "lwarneke@mikecarneytoyota.com.au",
117
+ "emailprinter": False,
118
+ "prtid": "",
119
+ "ddpflg": False,
120
+ "ddpquo": 0,
121
+ "submitopt": "p",
122
+ "email_staff": False,
123
+ "staff_email": "",
124
+ "email_other": False,
125
+ "other_email": "",
126
+ "subject": "Parts By Bin Location",
127
+ "attn": "",
128
+ "email_text": "",
129
+ "email_signature": "",
130
+ "email_sig_type": "D",
131
+ "frmdptid": "570",
132
+ "todptid": "570",
133
+ "frmfrnid": "",
134
+ "tofrnid": "",
135
+ "frmbinid": "",
136
+ "tobinid": "",
137
+ "frmmovecode": "",
138
+ "tomovecode": "",
139
+ "stktyp": "A",
140
+ "prntnotzero": False,
141
+ "prntzero": False,
142
+ "prntstkzero": False,
143
+ "noprimarybin": False,
144
+ "noalternatebin": False,
145
+ "hasalternateonly": False,
146
+ "bothprimaryalternate": False,
147
+ "lastsaledate": None,
148
+ "lastreceiptdate": None,
149
+ "prntavgcost": False,
150
+ "rptformat": "N",
151
+ "tasktype": "",
152
+ }
153
+ ],
154
+ "prods:before": {
155
+ "tt_params": [
156
+ {
157
+ "prods:id": "tt_paramsFldId1",
158
+ "prods:rowState": "modified",
159
+ "fldid": 1,
160
+ "coid": "03",
161
+ "divid": "1",
162
+ "activityid": "",
163
+ "taskid": "",
164
+ "tasksts": None,
165
+ "rptid": "",
166
+ "pdf": "",
167
+ "formprt": False,
168
+ "csvout": False,
169
+ "emailopt": "",
170
+ "emailme": False,
171
+ "useremail": "",
172
+ "emailprinter": False,
173
+ "prtid": "",
174
+ "ddpflg": False,
175
+ "ddpquo": 0,
176
+ "submitopt": "",
177
+ "email_staff": False,
178
+ "staff_email": "",
179
+ "email_other": False,
180
+ "other_email": "",
181
+ "subject": "",
182
+ "attn": "",
183
+ "email_text": "",
184
+ "email_signature": "",
185
+ "email_sig_type": "",
186
+ "frmdptid": "130",
187
+ "todptid": "130",
188
+ "frmfrnid": "",
189
+ "tofrnid": "",
190
+ "frmbinid": "",
191
+ "tobinid": "",
192
+ "frmmovecode": "",
193
+ "tomovecode": "",
194
+ "stktyp": "P",
195
+ "prntnotzero": False,
196
+ "prntzero": False,
197
+ "prntstkzero": False,
198
+ "noprimarybin": False,
199
+ "noalternatebin": False,
200
+ "hasalternateonly": False,
201
+ "bothprimaryalternate": False,
202
+ "lastsaledate": None,
203
+ "lastreceiptdate": None,
204
+ "prntavgcost": False,
205
+ "rptformat": "",
206
+ "tasktype": "",
207
+ }
208
+ ]
209
+ },
210
+ }
211
+ },
212
+ },
213
+ ],
214
+ "stopOnWarning": True,
215
+ "validateOnly": False,
216
+ "uiType": "ISC",
217
+ }
218
+
219
+
220
+ def download_parts_by_bin_report(
221
+ cookies_path: Optional[Path | str] = None,
222
+ output_path: Optional[Path | str] = None,
223
+ base_url: Optional[str] = None,
224
+ ) -> Path:
225
+ """
226
+ Run the Parts By Bin Location report and save CSV to output_path.
227
+ Returns the path where the file was saved.
228
+
229
+ Args:
230
+ cookies_path: Path to cookies JSON (Chrome export). Defaults to REVOLUTIONNEXT_COOKIES_PATH or current dir.
231
+ output_path: Where to save the CSV. Defaults to current dir / Parts_By_Bin_Location.csv.
232
+ base_url: Revolution Next base URL. Defaults to env.
233
+ """
234
+ base_url = base_url or get_revnext_base_url_from_env()
235
+ default_cookies = Path.cwd() / "revnext-cookies.json"
236
+ cookies_path = Path(cookies_path) if cookies_path is not None else default_cookies
237
+ output_path = Path(output_path) if output_path is not None else (Path.cwd() / "Parts_By_Bin_Location.csv")
238
+ session = create_session(cookies_path, SERVICE_OBJECT, base_url)
239
+ return run_report_flow(
240
+ session,
241
+ SERVICE_OBJECT,
242
+ ACTIVITY_TAB_ID,
243
+ _build_submit_body,
244
+ output_path,
245
+ base_url,
246
+ )
247
+
248
+
249
+ if __name__ == "__main__":
250
+ download_parts_by_bin_report()
@@ -0,0 +1,254 @@
1
+ """
2
+ Download Parts Price List CSV report from Revolution Next (*.revolutionnext.com.au).
3
+ Uses cookies (Chrome export format) and configurable base URL.
4
+ """
5
+
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import requests
11
+
12
+ from revnext.common import create_session, run_report_flow
13
+ from revnext.config import get_revnext_base_url_from_env
14
+
15
+ SERVICE_OBJECT = "Revolution.Activity.IM.RPT.PartsPriceListPR"
16
+ ACTIVITY_TAB_ID = "N78b54de4_7cdc_43e0_9e42_71a49bec44f2"
17
+
18
+
19
+ def _build_submit_body() -> dict:
20
+ """Build the submitActivityTask request body. Use today's date for the trigger."""
21
+ tz = "+10:00"
22
+ now = datetime.now()
23
+ start_date = now.strftime("%Y-%m-%d")
24
+ start_time = f"{start_date}T{now.strftime('%H:%M')}:00.000{tz}"
25
+ return {
26
+ "_userContext_vg_coid": "03",
27
+ "_userContext_vg_divid": "1",
28
+ "_userContext_vg_dftdpt": "570",
29
+ "activityTabId": ACTIVITY_TAB_ID,
30
+ "dataSets": [
31
+ {
32
+ "name": "dsActivityTask",
33
+ "id": None,
34
+ "dataSet": {
35
+ "dsActivityTask": {
36
+ "prods:hasChanges": True,
37
+ "ttActivityTask": [
38
+ {
39
+ "prods:id": "ttActivityTask1945856",
40
+ "prods:rowState": "created",
41
+ "fldId": 1,
42
+ "taskID": "",
43
+ "loadRowidPassThrough": "dummy",
44
+ "executeActivityTaskNow": False,
45
+ "executedAt": None,
46
+ "logMessages": "",
47
+ "startTime": None,
48
+ "endTime": None,
49
+ }
50
+ ],
51
+ "prods:before": {},
52
+ }
53
+ },
54
+ },
55
+ {
56
+ "name": "dsActivityTaskTriggers",
57
+ "id": None,
58
+ "dataSet": {
59
+ "dsActivityTaskTriggers": {
60
+ "prods:hasChanges": True,
61
+ "ttActivityTaskTrigger": [
62
+ {
63
+ "prods:id": "ttActivityTaskTrigger1786112",
64
+ "prods:rowState": "created",
65
+ "fldId": 1,
66
+ "mode": "O",
67
+ "startDateTime": start_time,
68
+ "startDate": f"{start_date}T00:00:00.000{tz}",
69
+ "startHour": now.hour,
70
+ "startMinute": now.minute,
71
+ "recurEvery": 1,
72
+ "recurEveryUOM": "days",
73
+ "weeklySun": False,
74
+ "weeklyMon": False,
75
+ "weeklyTue": False,
76
+ "weeklyWed": False,
77
+ "weeklyThu": False,
78
+ "weeklyFri": False,
79
+ "weeklySat": False,
80
+ "monthsList": "",
81
+ "monthlyMode": "",
82
+ "monthlyDaysList": "",
83
+ "monthlyOnWeekNumber": "",
84
+ "monthlyOnDayOfWeek": "",
85
+ "triggerDescription": "",
86
+ "triggerNextSchedule": None,
87
+ "windowTimeFrom": "",
88
+ "windowTimeTo": "",
89
+ "windowAllDay": False,
90
+ }
91
+ ],
92
+ "prods:before": {},
93
+ }
94
+ },
95
+ },
96
+ {
97
+ "name": "dsParams",
98
+ "id": None,
99
+ "dataSet": {
100
+ "dsParams": {
101
+ "prods:hasChanges": True,
102
+ "tt_params": [
103
+ {
104
+ "prods:id": "tt_paramsFldId1",
105
+ "prods:rowState": "modified",
106
+ "fldid": 1,
107
+ "coid": "03",
108
+ "divid": "1",
109
+ "activityid": "IM.RPT.PartsPriceListPR",
110
+ "taskid": "",
111
+ "tasksts": None,
112
+ "rptid": "",
113
+ "pdf": "",
114
+ "formprt": False,
115
+ "csvout": True,
116
+ "emailopt": "n",
117
+ "emailme": False,
118
+ "useremail": "lwarneke@mikecarneytoyota.com.au",
119
+ "emailprinter": False,
120
+ "prtid": "",
121
+ "ddpflg": False,
122
+ "ddpquo": 0,
123
+ "submitopt": "p",
124
+ "email_staff": False,
125
+ "staff_email": "",
126
+ "email_other": False,
127
+ "other_email": "",
128
+ "subject": "Parts Price List",
129
+ "attn": "",
130
+ "email_text": "",
131
+ "email_signature": "",
132
+ "email_sig_type": "D",
133
+ "prttyp": "s",
134
+ "dptid": "570",
135
+ "frnid": "",
136
+ "frnidto": "",
137
+ "binid": "",
138
+ "binidto": "",
139
+ "prctyp1": "L",
140
+ "prctyp2": "S",
141
+ "incgst1": True,
142
+ "incgst2": True,
143
+ "exportexcel": False,
144
+ "tasktype": "",
145
+ }
146
+ ],
147
+ "prods:before": {
148
+ "tt_params": [
149
+ {
150
+ "prods:id": "tt_paramsFldId1",
151
+ "prods:rowState": "modified",
152
+ "fldid": 1,
153
+ "coid": "03",
154
+ "divid": "1",
155
+ "activityid": "",
156
+ "taskid": "",
157
+ "tasksts": None,
158
+ "rptid": "",
159
+ "pdf": "",
160
+ "formprt": False,
161
+ "csvout": False,
162
+ "emailopt": "",
163
+ "emailme": False,
164
+ "useremail": "",
165
+ "emailprinter": False,
166
+ "prtid": "",
167
+ "ddpflg": False,
168
+ "ddpquo": 0,
169
+ "submitopt": "",
170
+ "email_staff": False,
171
+ "staff_email": "",
172
+ "email_other": False,
173
+ "other_email": "",
174
+ "subject": "",
175
+ "attn": "",
176
+ "email_text": "",
177
+ "email_signature": "",
178
+ "email_sig_type": "",
179
+ "prttyp": "s",
180
+ "dptid": "130",
181
+ "frnid": "",
182
+ "frnidto": "",
183
+ "binid": "",
184
+ "binidto": "",
185
+ "prctyp1": "",
186
+ "prctyp2": "",
187
+ "incgst1": False,
188
+ "incgst2": False,
189
+ "exportexcel": False,
190
+ "tasktype": "",
191
+ }
192
+ ]
193
+ },
194
+ }
195
+ },
196
+ },
197
+ ],
198
+ "stopOnWarning": True,
199
+ "validateOnly": False,
200
+ "uiType": "ISC",
201
+ }
202
+
203
+
204
+ def _post_submit_closesubmit_factory(base_url: str):
205
+ """Return a hook that calls onChoose_btn_closesubmit (required for Parts Price List flow)."""
206
+ def _post_submit_closesubmit(session: requests.Session) -> None:
207
+ url = f"{base_url}/next/rest/si/presenter/onChoose_btn_closesubmit"
208
+ body = {
209
+ "_userContext_vg_coid": "03",
210
+ "_userContext_vg_divid": "1",
211
+ "_userContext_vg_dftdpt": "570",
212
+ "activityTabId": ACTIVITY_TAB_ID,
213
+ "ctrlProp": [
214
+ {"name": "tt_params.submitopt", "prop": "SCREENVALUE", "value": "p"}
215
+ ],
216
+ "uiType": "ISC",
217
+ }
218
+ r = session.post(url, json=body)
219
+ r.raise_for_status()
220
+ return _post_submit_closesubmit
221
+
222
+
223
+ def download_parts_price_list_report(
224
+ cookies_path: Optional[Path | str] = None,
225
+ output_path: Optional[Path | str] = None,
226
+ base_url: Optional[str] = None,
227
+ ) -> Path:
228
+ """
229
+ Run the Parts Price List report and save CSV to output_path.
230
+ Returns the path where the file was saved.
231
+
232
+ Args:
233
+ cookies_path: Path to cookies JSON (Chrome export). Defaults to REVOLUTIONNEXT_COOKIES_PATH or current dir.
234
+ output_path: Where to save the CSV. Defaults to current dir / Parts_Price_List.csv.
235
+ base_url: Revolution Next base URL (e.g. https://yoursite.revolutionnext.com.au). Defaults to env.
236
+ """
237
+ base_url = base_url or get_revnext_base_url_from_env()
238
+ default_cookies = Path.cwd() / "revnext-cookies.json"
239
+ cookies_path = Path(cookies_path) if cookies_path is not None else default_cookies
240
+ output_path = Path(output_path) if output_path is not None else (Path.cwd() / "Parts_Price_List.csv")
241
+ session = create_session(cookies_path, SERVICE_OBJECT, base_url)
242
+ return run_report_flow(
243
+ session,
244
+ SERVICE_OBJECT,
245
+ ACTIVITY_TAB_ID,
246
+ _build_submit_body,
247
+ output_path,
248
+ base_url,
249
+ post_submit_hook=_post_submit_closesubmit_factory(base_url),
250
+ )
251
+
252
+
253
+ if __name__ == "__main__":
254
+ download_parts_price_list_report()
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: revnext
3
+ Version: 0.1.0
4
+ Summary: Revolution Next (*.revolutionnext.com.au) report downloads via REST API
5
+ License-Expression: MIT
6
+ Keywords: revnext,revolution-next,reports,api
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: requests
16
+
17
+ # revnext
18
+
19
+ Download Revolution Next (*.revolutionnext.com.au) reports (Parts Price List, Parts by Bin Location) via REST API using cookies/session.
20
+
21
+ Install: `pip install revnext`
22
+ Or from repo root: `pip install -e ./packages/revnext`
23
+
24
+ See the [main repo README](../README.md) for configuration and usage.
@@ -0,0 +1,10 @@
1
+ revnext/__init__.py,sha256=ffKmJTWIuN-4EZg042HHD-CFZ9_6Yl8ysgWT6ZN8ByY,580
2
+ revnext/common.py,sha256=QjXCGspk7kyvcLYKzzpnsF9CYm6K2ddaxyD5YwFsB6s,7140
3
+ revnext/config.py,sha256=QUZJeHtMUGGILUP_q6Ops0I_mlO8Swp9F6NrzEi17uQ,2039
4
+ revnext/download_all_reports.py,sha256=sjuy9aSH-fxRI2Z9YvHcQIlCY_hsAgttjcFD7_cog-I,1675
5
+ revnext/parts_by_bin_report.py,sha256=9RLZbqqmWeEoEV-FNBkieXT6_q2DFyrp-0c7sbLgxFI,11408
6
+ revnext/parts_price_list_report.py,sha256=dJUCcU2ICfVd8AYn28SvDcANGz-mMVBQaA8t4Fy1_yg,11205
7
+ revnext-0.1.0.dist-info/METADATA,sha256=UJ-4zJ4wQ4aMroYn5-wW_aH-ZCgEaYzPPuN_ibZAmqs,912
8
+ revnext-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
+ revnext-0.1.0.dist-info/top_level.txt,sha256=fW_mpOHYljzK-IPZOiJBGDIWkMmcbsjWzqjot5KDUwk,8
10
+ revnext-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ revnext