PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
worklist_endpoints.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
13// IMPORTANT: Include Crow FIRST before any PACS headers to avoid forward
14// declaration conflicts
15#include "crow.h"
16
17// Workaround for Windows: DELETE is defined as a macro in <winnt.h>
18// which conflicts with crow::HTTPMethod::DELETE
19#ifdef DELETE
20#undef DELETE
21#endif
22
29
30#include <chrono>
31#include <iomanip>
32#include <sstream>
33
35
36namespace {
37
41void add_cors_headers(crow::response &res, const rest_server_context &ctx) {
42 if (ctx.config && !ctx.config->cors_allowed_origins.empty()) {
43 res.add_header("Access-Control-Allow-Origin",
44 ctx.config->cors_allowed_origins);
45 }
46}
47
51std::string format_datetime(
52 const std::chrono::system_clock::time_point &tp) {
53 auto time = std::chrono::system_clock::to_time_t(tp);
54 std::ostringstream oss;
55 oss << std::put_time(std::gmtime(&time), "%Y-%m-%dT%H:%M:%SZ");
56 return oss.str();
57}
58
62std::string worklist_item_to_json(const storage::worklist_item &item) {
63 std::ostringstream oss;
64 oss << R"({"pk":)" << item.pk
65 << R"(,"step_id":")" << json_escape(item.step_id)
66 << R"(","step_status":")" << json_escape(item.step_status)
67 << R"(","patient_id":")" << json_escape(item.patient_id)
68 << R"(","patient_name":")" << json_escape(item.patient_name)
69 << R"(","birth_date":")" << json_escape(item.birth_date)
70 << R"(","sex":")" << json_escape(item.sex)
71 << R"(","accession_no":")" << json_escape(item.accession_no)
72 << R"(","requested_proc_id":")" << json_escape(item.requested_proc_id)
73 << R"(","study_uid":")" << json_escape(item.study_uid)
74 << R"(","scheduled_datetime":")" << json_escape(item.scheduled_datetime)
75 << R"(","station_ae":")" << json_escape(item.station_ae)
76 << R"(","station_name":")" << json_escape(item.station_name)
77 << R"(","modality":")" << json_escape(item.modality)
78 << R"(","procedure_desc":")" << json_escape(item.procedure_desc)
79 << R"(","protocol_code":")" << json_escape(item.protocol_code)
80 << R"(","referring_phys":")" << json_escape(item.referring_phys)
81 << R"(","referring_phys_id":")" << json_escape(item.referring_phys_id)
82 << R"(","created_at":")" << format_datetime(item.created_at)
83 << R"(","updated_at":")" << format_datetime(item.updated_at)
84 << R"("})";
85 return oss.str();
86}
87
91std::string worklist_items_to_json(
92 const std::vector<storage::worklist_item> &items,
93 size_t total_count) {
94 std::ostringstream oss;
95 oss << R"({"data":[)";
96 for (size_t i = 0; i < items.size(); ++i) {
97 if (i > 0) {
98 oss << ",";
99 }
100 oss << worklist_item_to_json(items[i]);
101 }
102 oss << R"(],"pagination":{"total":)" << total_count
103 << R"(,"count":)" << items.size() << "}}";
104 return oss.str();
105}
106
110std::pair<size_t, size_t> parse_pagination(const crow::request &req) {
111 size_t limit = 20; // Default limit
112 size_t offset = 0;
113
114 auto limit_param = req.url_params.get("limit");
115 if (limit_param) {
116 try {
117 limit = std::stoul(limit_param);
118 if (limit > 100) {
119 limit = 100; // Cap at 100
120 }
121 } catch (...) {
122 // Use default
123 }
124 }
125
126 auto offset_param = req.url_params.get("offset");
127 if (offset_param) {
128 try {
129 offset = std::stoul(offset_param);
130 } catch (...) {
131 // Use default
132 }
133 }
134
135 return {limit, offset};
136}
137
141std::optional<storage::worklist_item> parse_worklist_item_json(
142 const std::string &body) {
143 storage::worklist_item item;
144
145 // Simple JSON parsing for required fields
146 auto get_json_string = [&body](const std::string &key) -> std::string {
147 std::string search_key = "\"" + key + "\":\"";
148 auto pos = body.find(search_key);
149 if (pos == std::string::npos) {
150 return "";
151 }
152 pos += search_key.length();
153 auto end_pos = body.find("\"", pos);
154 if (end_pos == std::string::npos) {
155 return "";
156 }
157 return body.substr(pos, end_pos - pos);
158 };
159
160 item.step_id = get_json_string("step_id");
161 item.patient_id = get_json_string("patient_id");
162 item.patient_name = get_json_string("patient_name");
163 item.birth_date = get_json_string("birth_date");
164 item.sex = get_json_string("sex");
165 item.accession_no = get_json_string("accession_no");
166 item.requested_proc_id = get_json_string("requested_proc_id");
167 item.study_uid = get_json_string("study_uid");
168 item.scheduled_datetime = get_json_string("scheduled_datetime");
169 item.station_ae = get_json_string("station_ae");
170 item.station_name = get_json_string("station_name");
171 item.modality = get_json_string("modality");
172 item.procedure_desc = get_json_string("procedure_desc");
173 item.protocol_code = get_json_string("protocol_code");
174 item.referring_phys = get_json_string("referring_phys");
175 item.referring_phys_id = get_json_string("referring_phys_id");
176 item.step_status = get_json_string("step_status");
177
178 // Set default status if not provided
179 if (item.step_status.empty()) {
180 item.step_status = "SCHEDULED";
181 }
182
183 // Validate required fields
184 if (!item.is_valid()) {
185 return std::nullopt;
186 }
187
188 return item;
189}
190
191} // namespace
192
193// Internal implementation function called from rest_server.cpp
195 crow::SimpleApp &app,
196 std::shared_ptr<rest_server_context> ctx) {
197 // GET /api/v1/worklist - List scheduled procedures (paginated)
198 CROW_ROUTE(app, "/api/v1/worklist")
199 .methods(crow::HTTPMethod::GET)([ctx](const crow::request &req) {
200 crow::response res;
201 res.add_header("Content-Type", "application/json");
202 add_cors_headers(res, *ctx);
203
204 if (!ctx->database) {
205 res.code = 503;
206 res.body =
207 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
208 return res;
209 }
210
211 // Parse pagination
212 auto [limit, offset] = parse_pagination(req);
213
214 // Build query from URL parameters
216 query.limit = limit;
217 query.offset = offset;
218
219 auto station_ae = req.url_params.get("station_ae");
220 if (station_ae) {
221 query.station_ae = station_ae;
222 }
223
224 auto modality = req.url_params.get("modality");
225 if (modality) {
226 query.modality = modality;
227 }
228
229 auto scheduled_date_from = req.url_params.get("scheduled_date_from");
230 if (scheduled_date_from) {
231 query.scheduled_date_from = scheduled_date_from;
232 }
233
234 auto scheduled_date_to = req.url_params.get("scheduled_date_to");
235 if (scheduled_date_to) {
236 query.scheduled_date_to = scheduled_date_to;
237 }
238
239 auto patient_id = req.url_params.get("patient_id");
240 if (patient_id) {
241 query.patient_id = patient_id;
242 }
243
244 auto patient_name = req.url_params.get("patient_name");
245 if (patient_name) {
246 query.patient_name = patient_name;
247 }
248
249 auto accession_no = req.url_params.get("accession_no");
250 if (accession_no) {
251 query.accession_no = accession_no;
252 }
253
254 auto step_id = req.url_params.get("step_id");
255 if (step_id) {
256 query.step_id = step_id;
257 }
258
259 // Check if we should include all statuses
260 auto include_all = req.url_params.get("include_all_status");
261 if (include_all && std::string(include_all) == "true") {
262 query.include_all_status = true;
263 }
264
265 // Get total count (without pagination)
266 storage::worklist_query count_query = query;
267 count_query.limit = 0;
268 count_query.offset = 0;
269 auto all_items_result = ctx->database->query_worklist(count_query);
270 if (!all_items_result.is_ok()) {
271 res.code = 500;
272 res.body = make_error_json("QUERY_ERROR",
273 all_items_result.error().message);
274 return res;
275 }
276 size_t total_count = all_items_result.value().size();
277
278 // Get paginated results
279 auto items_result = ctx->database->query_worklist(query);
280 if (!items_result.is_ok()) {
281 res.code = 500;
282 res.body = make_error_json("QUERY_ERROR",
283 items_result.error().message);
284 return res;
285 }
286
287 res.code = 200;
288 res.body = worklist_items_to_json(items_result.value(), total_count);
289 return res;
290 });
291
292 // POST /api/v1/worklist - Create worklist item
293 CROW_ROUTE(app, "/api/v1/worklist")
294 .methods(crow::HTTPMethod::POST)([ctx](const crow::request &req) {
295 crow::response res;
296 res.add_header("Content-Type", "application/json");
297 add_cors_headers(res, *ctx);
298
299 if (!ctx->database) {
300 res.code = 503;
301 res.body =
302 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
303 return res;
304 }
305
306 // Parse request body
307 auto item = parse_worklist_item_json(req.body);
308 if (!item) {
309 res.code = 400;
310 res.body = make_error_json(
311 "INVALID_REQUEST",
312 "Missing required fields: step_id, patient_id, modality, "
313 "scheduled_datetime");
314 return res;
315 }
316
317 // Add worklist item
318 auto result = ctx->database->add_worklist_item(*item);
319 if (result.is_err()) {
320 res.code = 500;
321 res.body = make_error_json("CREATE_FAILED", result.error().message);
322 return res;
323 }
324
325 // Fetch the created item to return
326 auto created_item = ctx->database->find_worklist_by_pk(result.value());
327 if (created_item) {
328 res.code = 201;
329 res.body = worklist_item_to_json(*created_item);
330 } else {
331 res.code = 201;
332 res.body = make_success_json("Worklist item created");
333 }
334 return res;
335 });
336
337 // GET /api/v1/worklist/:id - Get worklist item by pk
338 CROW_ROUTE(app, "/api/v1/worklist/<int>")
339 .methods(crow::HTTPMethod::GET)(
340 [ctx](const crow::request & /*req*/, int pk) {
341 crow::response res;
342 res.add_header("Content-Type", "application/json");
343 add_cors_headers(res, *ctx);
344
345 if (!ctx->database) {
346 res.code = 503;
347 res.body = make_error_json("DATABASE_UNAVAILABLE",
348 "Database not configured");
349 return res;
350 }
351
352 auto item = ctx->database->find_worklist_by_pk(pk);
353 if (!item) {
354 res.code = 404;
355 res.body = make_error_json("NOT_FOUND", "Worklist item not found");
356 return res;
357 }
358
359 res.code = 200;
360 res.body = worklist_item_to_json(*item);
361 return res;
362 });
363
364 // PUT /api/v1/worklist/:id - Update worklist item
365 CROW_ROUTE(app, "/api/v1/worklist/<int>")
366 .methods(crow::HTTPMethod::PUT)(
367 [ctx](const crow::request &req, int pk) {
368 crow::response res;
369 res.add_header("Content-Type", "application/json");
370 add_cors_headers(res, *ctx);
371
372 if (!ctx->database) {
373 res.code = 503;
374 res.body = make_error_json("DATABASE_UNAVAILABLE",
375 "Database not configured");
376 return res;
377 }
378
379 // Verify item exists
380 auto existing_item = ctx->database->find_worklist_by_pk(pk);
381 if (!existing_item) {
382 res.code = 404;
383 res.body = make_error_json("NOT_FOUND", "Worklist item not found");
384 return res;
385 }
386
387 // Parse new status from request body
388 auto get_json_string = [&req](const std::string &key) -> std::string {
389 std::string search_key = "\"" + key + "\":\"";
390 auto pos = req.body.find(search_key);
391 if (pos == std::string::npos) {
392 return "";
393 }
394 pos += search_key.length();
395 auto end_pos = req.body.find("\"", pos);
396 if (end_pos == std::string::npos) {
397 return "";
398 }
399 return req.body.substr(pos, end_pos - pos);
400 };
401
402 std::string new_status = get_json_string("step_status");
403 if (new_status.empty()) {
404 res.code = 400;
405 res.body = make_error_json("INVALID_REQUEST",
406 "step_status is required for update");
407 return res;
408 }
409
410 // Update status
411 auto result = ctx->database->update_worklist_status(
412 existing_item->step_id,
413 existing_item->accession_no,
414 new_status);
415
416 if (result.is_err()) {
417 res.code = 500;
418 res.body = make_error_json("UPDATE_FAILED", result.error().message);
419 return res;
420 }
421
422 // Fetch updated item
423 auto updated_item = ctx->database->find_worklist_by_pk(pk);
424 if (updated_item) {
425 res.code = 200;
426 res.body = worklist_item_to_json(*updated_item);
427 } else {
428 res.code = 200;
429 res.body = make_success_json("Worklist item updated");
430 }
431 return res;
432 });
433
434 // DELETE /api/v1/worklist/:id - Delete worklist item
435 CROW_ROUTE(app, "/api/v1/worklist/<int>")
436 .methods(crow::HTTPMethod::DELETE)(
437 [ctx](const crow::request & /*req*/, int pk) {
438 crow::response res;
439 res.add_header("Content-Type", "application/json");
440 add_cors_headers(res, *ctx);
441
442 if (!ctx->database) {
443 res.code = 503;
444 res.body = make_error_json("DATABASE_UNAVAILABLE",
445 "Database not configured");
446 return res;
447 }
448
449 // Verify item exists
450 auto item = ctx->database->find_worklist_by_pk(pk);
451 if (!item) {
452 res.code = 404;
453 res.body = make_error_json("NOT_FOUND", "Worklist item not found");
454 return res;
455 }
456
457 auto result = ctx->database->delete_worklist_item(
458 item->step_id, item->accession_no);
459 if (result.is_err()) {
460 res.code = 500;
461 res.body = make_error_json("DELETE_FAILED", result.error().message);
462 return res;
463 }
464
465 res.code = 200;
466 res.body = make_success_json("Worklist item deleted successfully");
467 return res;
468 });
469}
470
471} // namespace kcenon::pacs::web::endpoints
PACS index database for metadata storage and retrieval.
constexpr dicom_tag item
Item.
void register_worklist_endpoints_impl(crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
std::string make_error_json(std::string_view code, std::string_view message)
Create JSON error response body with details.
Definition rest_types.h:79
std::string json_escape(std::string_view s)
Escape a string for JSON.
Definition rest_types.h:101
std::string make_success_json(std::string_view message="OK")
Create success response with optional message.
Definition rest_types.h:91
Configuration for REST API server.
Common types and utilities for REST API.
size_t offset
Offset for pagination.
size_t limit
Maximum number of results to return (0 = unlimited)
System API endpoints for REST server.
Worklist API endpoints for REST server.
Modality Worklist (MWL) record data structures.