pyxecm 1.6__py3-none-any.whl → 2.0.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.

Potentially problematic release.


This version of pyxecm might be problematic. Click here for more details.

Files changed (56) hide show
  1. pyxecm/__init__.py +6 -4
  2. pyxecm/avts.py +673 -246
  3. pyxecm/coreshare.py +686 -467
  4. pyxecm/customizer/__init__.py +16 -4
  5. pyxecm/customizer/__main__.py +58 -0
  6. pyxecm/customizer/api/__init__.py +5 -0
  7. pyxecm/customizer/api/__main__.py +6 -0
  8. pyxecm/customizer/api/app.py +914 -0
  9. pyxecm/customizer/api/auth.py +154 -0
  10. pyxecm/customizer/api/metrics.py +92 -0
  11. pyxecm/customizer/api/models.py +13 -0
  12. pyxecm/customizer/api/payload_list.py +865 -0
  13. pyxecm/customizer/api/settings.py +103 -0
  14. pyxecm/customizer/browser_automation.py +332 -139
  15. pyxecm/customizer/customizer.py +1007 -1130
  16. pyxecm/customizer/exceptions.py +35 -0
  17. pyxecm/customizer/guidewire.py +322 -0
  18. pyxecm/customizer/k8s.py +713 -378
  19. pyxecm/customizer/log.py +107 -0
  20. pyxecm/customizer/m365.py +2867 -909
  21. pyxecm/customizer/nhc.py +1169 -0
  22. pyxecm/customizer/openapi.py +258 -0
  23. pyxecm/customizer/payload.py +16817 -7467
  24. pyxecm/customizer/pht.py +699 -285
  25. pyxecm/customizer/salesforce.py +516 -342
  26. pyxecm/customizer/sap.py +58 -41
  27. pyxecm/customizer/servicenow.py +593 -371
  28. pyxecm/customizer/settings.py +442 -0
  29. pyxecm/customizer/successfactors.py +408 -346
  30. pyxecm/customizer/translate.py +83 -48
  31. pyxecm/helper/__init__.py +5 -2
  32. pyxecm/helper/assoc.py +83 -43
  33. pyxecm/helper/data.py +2406 -870
  34. pyxecm/helper/logadapter.py +27 -0
  35. pyxecm/helper/web.py +229 -101
  36. pyxecm/helper/xml.py +527 -171
  37. pyxecm/maintenance_page/__init__.py +5 -0
  38. pyxecm/maintenance_page/__main__.py +6 -0
  39. pyxecm/maintenance_page/app.py +51 -0
  40. pyxecm/maintenance_page/settings.py +28 -0
  41. pyxecm/maintenance_page/static/favicon.avif +0 -0
  42. pyxecm/maintenance_page/templates/maintenance.html +165 -0
  43. pyxecm/otac.py +234 -140
  44. pyxecm/otawp.py +1436 -557
  45. pyxecm/otcs.py +7716 -3161
  46. pyxecm/otds.py +2150 -919
  47. pyxecm/otiv.py +36 -21
  48. pyxecm/otmm.py +1272 -325
  49. pyxecm/otpd.py +231 -127
  50. pyxecm-2.0.0.dist-info/METADATA +145 -0
  51. pyxecm-2.0.0.dist-info/RECORD +54 -0
  52. {pyxecm-1.6.dist-info → pyxecm-2.0.0.dist-info}/WHEEL +1 -1
  53. pyxecm-1.6.dist-info/METADATA +0 -53
  54. pyxecm-1.6.dist-info/RECORD +0 -32
  55. {pyxecm-1.6.dist-info → pyxecm-2.0.0.dist-info/licenses}/LICENSE +0 -0
  56. {pyxecm-1.6.dist-info → pyxecm-2.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,914 @@
1
+ """API Implemenation for the Customizer to start and control the payload processing.
2
+
3
+ Endpoints:
4
+
5
+ GET /app/v1/payload - get processing list of payloads
6
+ POST /app/v1/payload - add new payload to processing list
7
+ GET /api/v1/payload/{payload_id} - get a specific payload
8
+ GET /api/v1/payload/{payload_id}/content - get a specific payload content
9
+ GET /api/v1/payload/{payload_id}/log - get a specific payload content
10
+ GET /api/v1/payload/{payload_id}/log - get a specific payload content
11
+
12
+ """
13
+
14
+ __author__ = "Dr. Marc Diefenbruch"
15
+ __copyright__ = "Copyright (C) 2024-2025, OpenText"
16
+ __credits__ = ["Kai-Philip Gatzweiler"]
17
+ __maintainer__ = "Dr. Marc Diefenbruch"
18
+ __email__ = "mdiefenb@opentext.com"
19
+
20
+ import logging
21
+ import os
22
+ import shutil
23
+ import signal
24
+ import sys
25
+ from contextlib import asynccontextmanager
26
+ from datetime import datetime, timezone
27
+ from importlib.metadata import version
28
+ from threading import Thread
29
+ from typing import Annotated, Literal
30
+
31
+ import uvicorn
32
+ import yaml
33
+ from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
34
+ from fastapi.middleware.cors import CORSMiddleware
35
+ from fastapi.responses import FileResponse, JSONResponse, Response
36
+ from fastapi.security import OAuth2PasswordBearer
37
+ from prometheus_fastapi_instrumentator import Instrumentator
38
+ from pydantic import HttpUrl, ValidationError
39
+
40
+ from pyxecm.customizer import K8s
41
+ from pyxecm.customizer.api import auth, models
42
+ from pyxecm.customizer.api.metrics import payload_logs_by_payload, payload_logs_total
43
+ from pyxecm.customizer.api.payload_list import PayloadList
44
+ from pyxecm.customizer.api.settings import api_settings
45
+ from pyxecm.customizer.exceptions import PayloadImportError
46
+ from pyxecm.customizer.payload import load_payload
47
+ from pyxecm.maintenance_page import run_maintenance_page
48
+ from pyxecm.maintenance_page import settings as maint_settings
49
+
50
+ # Check if Temp dir exists
51
+ if not os.path.exists(api_settings.temp_dir):
52
+ os.makedirs(api_settings.temp_dir)
53
+
54
+ # Check if Logfile and folder exists and is unique
55
+ if os.path.isfile(os.path.join(api_settings.logfolder, api_settings.logfile)):
56
+ customizer_start_time = datetime.now(timezone.utc).strftime(
57
+ "%Y-%m-%d_%H-%M",
58
+ )
59
+ api_settings.logfile = f"customizer_{customizer_start_time}.log"
60
+ elif not os.path.exists(api_settings.logfolder):
61
+ os.makedirs(api_settings.logfolder)
62
+
63
+
64
+ handlers = [
65
+ logging.FileHandler(os.path.join(api_settings.logfolder, api_settings.logfile)),
66
+ logging.StreamHandler(sys.stdout),
67
+ ]
68
+
69
+ logging.basicConfig(
70
+ format="%(asctime)s %(levelname)s [%(name)s] [%(threadName)s] %(message)s",
71
+ datefmt="%d-%b-%Y %H:%M:%S",
72
+ level=api_settings.loglevel,
73
+ handlers=handlers,
74
+ )
75
+
76
+
77
+ @asynccontextmanager
78
+ async def lifespan( # noqa: ANN201
79
+ app: FastAPI,
80
+ ): # pylint: disable=unused-argument,redefined-outer-name
81
+ """Lifespan Method for FASTAPI to handle the startup and shutdown process.
82
+
83
+ Args:
84
+ app (FastAPI):
85
+ The application.
86
+
87
+ """
88
+
89
+ app.logger.debug("Settings -> %s", api_settings)
90
+
91
+ if api_settings.import_payload:
92
+ app.logger.info("Importing filesystem payloads...")
93
+
94
+ # Base Payload
95
+ import_payload(payload=api_settings.payload)
96
+
97
+ # External Payload
98
+ import_payload(payload_dir=api_settings.payload_dir, dependencies=True)
99
+
100
+ # Optional Payload
101
+ import_payload(payload_dir=api_settings.payload_dir_optional)
102
+
103
+ if api_settings.maintenance_mode:
104
+ app.logger.info("Starting maintenance_page thread...")
105
+ maint_thread = Thread(target=run_maintenance_page, name="maintenance_page")
106
+ maint_thread.start()
107
+
108
+ app.logger.info("Starting processing thread...")
109
+ thread = Thread(
110
+ target=payload_list.run_payload_processing,
111
+ name="customization_run_api",
112
+ )
113
+ thread.start()
114
+
115
+ yield
116
+ app.logger.info("Shutdown")
117
+ payload_list.stop_payload_processing()
118
+
119
+
120
+ app = FastAPI(
121
+ docs_url="/api",
122
+ title="Customizer API",
123
+ openapi_url="/api/openapi.json",
124
+ lifespan=lifespan,
125
+ version=version("pyxecm"),
126
+ openapi_tags=[
127
+ {
128
+ "name": "status",
129
+ "description": "Status of Customizer",
130
+ },
131
+ {
132
+ "name": "auth",
133
+ "description": "Authentication Endpoint - Users are authenticated against Opentext Directory Services",
134
+ },
135
+ {
136
+ "name": "payload",
137
+ "description": "Get status and manipulate payload objects ",
138
+ },
139
+ {
140
+ "name": "maintenance",
141
+ "description": "Enable, disable or alter the maintenance mode.",
142
+ },
143
+ ],
144
+ )
145
+ app.logger = logging.getLogger("CustomizerAPI")
146
+ app.add_middleware(
147
+ CORSMiddleware,
148
+ allow_origins=api_settings.trusted_origins,
149
+ allow_credentials=True,
150
+ allow_methods=["*"],
151
+ allow_headers=["*"],
152
+ )
153
+ app.k8s_object = K8s(logger=app.logger, namespace=api_settings.namespace)
154
+ app.include_router(auth.router)
155
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
156
+
157
+ # Initialize the globel Payloadlist object
158
+ payload_list = PayloadList(logger=app.logger)
159
+
160
+ if api_settings.metrics:
161
+ # Add Prometheus Instrumentator for /metrics
162
+ instrumentator = Instrumentator().instrument(app).expose(app)
163
+ instrumentator.add(payload_logs_by_payload(payload_list))
164
+ instrumentator.add(payload_logs_total(payload_list))
165
+
166
+
167
+ @app.get(path="/status", name="Get Status", tags=["status"])
168
+ async def get_status() -> dict:
169
+ """Get the status of the Customizer."""
170
+
171
+ df = payload_list.get_payload_items()
172
+
173
+ if df is None:
174
+ raise HTTPException(
175
+ status_code=500,
176
+ detail="Payload list is empty.",
177
+ )
178
+
179
+ all_status = df["status"].value_counts().to_dict()
180
+
181
+ return {
182
+ "version": "2",
183
+ "customizer_duration": (all_status.get("running", None)),
184
+ "customizer_end_time": None,
185
+ "customizer_start_time": None,
186
+ "status_details": all_status,
187
+ "status": "Running" if "running" in all_status else "Stopped",
188
+ "debug": df["log_debug"].sum(),
189
+ "info": df["log_info"].sum(),
190
+ "warning": df["log_warning"].sum(),
191
+ "error": df["log_error"].sum(),
192
+ "critical": df["log_critical"].sum(),
193
+ }
194
+
195
+
196
+ def import_payload(
197
+ payload: str | None = None,
198
+ payload_dir: str | None = None,
199
+ enabled: bool | None = None,
200
+ dependencies: bool | None = None,
201
+ ) -> None:
202
+ """Automatically load payload items from disk of a given directory.
203
+
204
+ Args:
205
+ payload (str):
206
+ The name of the payload.
207
+ payload_dir (str):
208
+ The local path.
209
+ enabled (bool, optional):
210
+ Automatically start the processing (True), or only define items (False).
211
+ Defaults to False.
212
+ dependencies (bool, optional):
213
+ Automatically add dependency on the last payload in the queue
214
+
215
+ """
216
+
217
+ def import_payload_file(
218
+ filename: str,
219
+ enabled: bool | None,
220
+ dependencies: bool | None,
221
+ ) -> None:
222
+ if not os.path.isfile(filename):
223
+ return
224
+
225
+ if not (filename.endswith((".yaml", ".tfvars", ".tf", ".yml.gz.b64"))):
226
+ app.logger.debug("Skipping file: %s", filename)
227
+ return
228
+
229
+ # Load payload file
230
+ payload_content = load_payload(filename)
231
+ if payload_content is None:
232
+ exception = f"The import of payload -> {filename} failed. Payload content could not be loaded."
233
+ raise PayloadImportError(exception)
234
+
235
+ payload_options = payload_content.get("payloadOptions", {})
236
+
237
+ if enabled is None:
238
+ enabled = payload_options.get("enabled", True)
239
+
240
+ # read name from options section if specified, otherwise take filename
241
+ name = payload_options.get("name", os.path.basename(filename))
242
+
243
+ # Get the loglevel from payloadOptions if set, otherwise use the default loglevel
244
+ loglevel = payload_options.get("loglevel", api_settings.loglevel)
245
+
246
+ # Get the git_url
247
+ git_url = payload_options.get("git_url", None)
248
+
249
+ # Dependency Management
250
+ if dependencies is None:
251
+ dependencies = []
252
+
253
+ # Get all dependencies from payloadOptions and resolve their ID
254
+ for dependency_name in payload_options.get("dependencies", []):
255
+ dependend_item = payload_list.get_payload_item_by_name(dependency_name)
256
+
257
+ if dependend_item is None:
258
+ exception = (
259
+ f"The import of payload -> {name} failed. Dependencies cannot be resovled: {dependency_name}",
260
+ )
261
+ raise PayloadImportError(
262
+ exception,
263
+ )
264
+ # Add the ID to the list of dependencies
265
+ dependencies.append(dependend_item["index"])
266
+
267
+ elif dependencies:
268
+ try:
269
+ payload_items = len(payload_list.get_payload_items()) - 1
270
+ dependencies = [payload_items] if payload_items != -1 else []
271
+ except Exception:
272
+ dependencies = []
273
+ else:
274
+ dependencies = []
275
+
276
+ app.logger.info("Adding payload: %s", filename)
277
+ payload = payload_list.add_payload_item(
278
+ name=name,
279
+ filename=filename,
280
+ status="planned",
281
+ logfile=f"{api_settings.logfolder}/{name}.log",
282
+ dependencies=dependencies,
283
+ enabled=enabled,
284
+ git_url=git_url,
285
+ loglevel=loglevel,
286
+ )
287
+ dependencies = payload["index"]
288
+
289
+ return
290
+
291
+ if payload is None and payload_dir is None:
292
+ exception = "No payload or payload_dir provided"
293
+ raise ValueError(exception)
294
+
295
+ if payload and os.path.isdir(payload) and payload_dir is None:
296
+ payload_dir = payload
297
+
298
+ if payload_dir is None:
299
+ import_payload_file(payload, enabled, dependencies)
300
+ return
301
+ elif not os.path.isdir(payload_dir):
302
+ return
303
+
304
+ for filename in sorted(os.listdir(payload_dir)):
305
+ try:
306
+ import_payload_file(os.path.join(payload_dir, filename), enabled, dependencies)
307
+ except PayloadImportError:
308
+ app.logger.error("Payload import failed")
309
+
310
+
311
+ def prepare_dependencies(dependencies: list) -> list | None:
312
+ """Convert the dependencies string to a list of integers."""
313
+ try:
314
+ list_all = dependencies[0].split(",")
315
+ except IndexError:
316
+ return None
317
+
318
+ # Remove empty values from the list
319
+ items = list(filter(None, list_all))
320
+ converted_list = []
321
+ for item in items:
322
+ try:
323
+ converted_list.append(int(item))
324
+ except ValueError:
325
+ continue
326
+
327
+ return converted_list
328
+
329
+
330
+ @app.post(path="/api/v1/payload", status_code=201, tags=["payload"])
331
+ def create_payload_item(
332
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # noqa: ARG001
333
+ upload_file: Annotated[UploadFile, File(...)],
334
+ name: Annotated[str, Form()] = "",
335
+ dependencies: Annotated[list[int] | list[str] | None, Form()] = None,
336
+ enabled: Annotated[bool, Form()] = True,
337
+ loglevel: Annotated[
338
+ Literal["DEBUG", "INFO", "WARNING"] | None,
339
+ Form(
340
+ description="Loglevel for the Payload processing",
341
+ ),
342
+ ] = "INFO",
343
+ ) -> dict:
344
+ """Upload a new payload item.
345
+
346
+ Args:
347
+ upload_file (UploadFile, optional):
348
+ The file to upload. Defaults to File(...).
349
+ name (str, optional):
350
+ The name of the payload (if not provided we will use the file name).
351
+ dependencies (list of integers):
352
+ List of other payload items this item depends on.
353
+ enabled (bool):
354
+ Flag indicating if the payload is enabled or not.
355
+ loglevel (str, optional):
356
+ The loglevel for the payload processing. Defaults to "INFO".
357
+ user (models.User, optional):
358
+ The user who is uploading the payload. Defaults to None.
359
+
360
+ Raises:
361
+ HTTPException:
362
+ Raised, if payload list is not initialized.
363
+
364
+ Returns:
365
+ dict:
366
+ The HTTP response.
367
+
368
+ """
369
+ if dependencies:
370
+ dependencies = prepare_dependencies(dependencies)
371
+
372
+ # Set name if not provided
373
+ name = name or os.path.splitext(os.path.basename(upload_file.filename))[0]
374
+ file_extension = os.path.splitext(upload_file.filename)[1]
375
+ file_name = os.path.join(api_settings.temp_dir, f"{name}{file_extension}")
376
+
377
+ with open(file_name, "wb") as buffer:
378
+ shutil.copyfileobj(upload_file.file, buffer)
379
+
380
+ if dependencies == [-1]:
381
+ dependencies = []
382
+
383
+ return payload_list.add_payload_item(
384
+ name=name,
385
+ filename=file_name,
386
+ status="planned",
387
+ logfile=os.path.join(api_settings.temp_dir, "{}.log".format(name)),
388
+ dependencies=dependencies or [],
389
+ enabled=enabled,
390
+ loglevel=loglevel,
391
+ )
392
+
393
+
394
+ @app.get(path="/api/v1/payload", tags=["payload"])
395
+ async def get_payload_items(
396
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # noqa: ARG001
397
+ ) -> dict:
398
+ """Get all Payload items.
399
+
400
+ Raises:
401
+ HTTPException: payload list not initialized
402
+ HTTPException: payload list is empty
403
+
404
+ Returns:
405
+ dict:
406
+ HTTP response with the result data
407
+
408
+ """
409
+
410
+ df = payload_list.get_payload_items()
411
+
412
+ if df is None:
413
+ raise HTTPException(
414
+ status_code=500,
415
+ detail="Payload list is empty.",
416
+ )
417
+
418
+ data = [{"index": idx, **row} for idx, row in df.iterrows()]
419
+
420
+ stats = {
421
+ "count": len(df),
422
+ "status": df["status"].value_counts().to_dict(),
423
+ "logs": {
424
+ "debug": df["log_debug"].sum(),
425
+ "info": df["log_info"].sum(),
426
+ "warning": df["log_warning"].sum(),
427
+ "error": df["log_error"].sum(),
428
+ "critical": df["log_critical"].sum(),
429
+ },
430
+ }
431
+
432
+ return {"stats": stats, "results": data}
433
+
434
+
435
+ @app.get(path="/api/v1/payload/{payload_id}", tags=["payload"])
436
+ async def get_payload_item(
437
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # noqa: ARG001
438
+ payload_id: int,
439
+ ) -> dict:
440
+ """Get a payload item based on its ID.
441
+
442
+ Args:
443
+ user: Annotated[models.User, Depends(auth.get_authorized_user)]
444
+ payload_id (int): payload item ID
445
+
446
+ Raises:
447
+ HTTPException: a payload item with the given ID couldn't be found
448
+
449
+ Returns:
450
+ dict:
451
+ HTTP response.
452
+
453
+ """
454
+ data = payload_list.get_payload_item(index=payload_id)
455
+
456
+ if data is None:
457
+ raise HTTPException(
458
+ status_code=404,
459
+ detail="Payload with index -> {} not found".format(payload_id),
460
+ )
461
+
462
+ return {"index": payload_id, **data}
463
+
464
+
465
+ @app.put(path="/api/v1/payload/{payload_id}", status_code=200, tags=["payload"])
466
+ async def update_payload_item(
467
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # noqa: ARG001
468
+ payload_id: int,
469
+ name: Annotated[str | None, Form()] = None,
470
+ dependencies: Annotated[list[int] | list[str] | None, Form()] = None,
471
+ enabled: Annotated[bool | None, Form()] = None,
472
+ status: Annotated[Literal["planned", "completed"] | None, Form()] = None,
473
+ loglevel: Annotated[
474
+ Literal["DEBUG", "INFO", "WARNING"] | None,
475
+ Form(
476
+ description="Loglevel for the Payload processing",
477
+ ),
478
+ ] = None,
479
+ ) -> dict:
480
+ """Update an existing payload item.
481
+
482
+ Args:
483
+ user (Optional[models.User]): User performing the update.
484
+ payload_id (int): ID of the payload to update.
485
+ upload_file (UploadFile, optional): replace the file name
486
+ name (Optional[str]): Updated name.
487
+ dependencies (Optional[List[int]]): Updated list of dependencies.
488
+ enabled (Optional[bool]): Updated enabled status.
489
+ loglevel (Optional[str]): Updated loglevel.
490
+ status (Optional[str]): Updated status.
491
+
492
+ Returns:
493
+ dict: HTTP response with the updated payload details.
494
+
495
+ """
496
+
497
+ if dependencies:
498
+ dependencies = prepare_dependencies(dependencies)
499
+ # Check if the payload exists
500
+ payload_item = payload_list.get_payload_item(
501
+ payload_id,
502
+ ) # Assumes a method to retrieve payload by ID
503
+ if payload_item is None:
504
+ raise HTTPException(
505
+ status_code=404,
506
+ detail="Payload with ID -> {} not found.".format(payload_id),
507
+ )
508
+
509
+ update_data = {}
510
+
511
+ # Update fields if provided
512
+ if name is not None:
513
+ update_data["name"] = name
514
+ if dependencies is not None:
515
+ update_data["dependencies"] = dependencies
516
+ if enabled is not None:
517
+ update_data["enabled"] = enabled
518
+ if status is not None:
519
+ update_data["status"] = status
520
+ if loglevel is not None:
521
+ update_data["loglevel"] = loglevel
522
+
523
+ if "status" in update_data and update_data["status"] == "planned":
524
+ app.logger.info("Resetting log message counters for -> %s", payload_id)
525
+ update_data["log_debug"] = 0
526
+ update_data["log_info"] = 0
527
+ update_data["log_warning"] = 0
528
+ update_data["log_error"] = 0
529
+ update_data["log_critical"] = 0
530
+
531
+ update_data["start_time"] = None
532
+ update_data["stop_time"] = None
533
+ update_data["duration"] = None
534
+
535
+ data = payload_list.get_payload_item(index=payload_id)
536
+ if os.path.isfile(data.logfile):
537
+ app.logger.info(
538
+ "Deleting log file (for payload) -> %s (%s)",
539
+ data.logfile,
540
+ payload_id,
541
+ )
542
+
543
+ now = datetime.now(timezone.utc)
544
+ old_log_name = (
545
+ os.path.dirname(data.logfile)
546
+ + "/"
547
+ + os.path.splitext(os.path.basename(data.logfile))[0]
548
+ + now.strftime("_%Y-%m-%d_%H-%M-%S.log")
549
+ )
550
+
551
+ os.rename(data.logfile, old_log_name)
552
+
553
+ # Save the updated payload back to the list (or database)
554
+ result = payload_list.update_payload_item(
555
+ index=payload_id,
556
+ update_data=update_data,
557
+ ) # Assumes a method to update the payload
558
+ if not result:
559
+ raise HTTPException(
560
+ status_code=404,
561
+ detail="Failed to update Payload with ID -> {} with data -> {}".format(
562
+ payload_id,
563
+ update_data,
564
+ ),
565
+ )
566
+
567
+ return {
568
+ "message": "Payload updated successfully",
569
+ "payload": {**payload_list.get_payload_item(index=payload_id)},
570
+ "updated_fields": update_data,
571
+ }
572
+
573
+
574
+ @app.delete(path="/api/v1/payload/{payload_id}", status_code=204, tags=["payload"])
575
+ async def delete_payload_item(
576
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # noqa: ARG001
577
+ payload_id: int,
578
+ ) -> JSONResponse:
579
+ """Delete an existing payload item.
580
+
581
+ Args:
582
+ user (Optional[models.User]): User performing the update.
583
+ payload_id (int): The ID of the payload to update.
584
+
585
+ Returns:
586
+ dict: response or None
587
+
588
+ """
589
+
590
+ # Check if the payload exists
591
+ result = payload_list.remove_payload_item(payload_id)
592
+ if not result:
593
+ raise HTTPException(
594
+ status_code=404,
595
+ detail="Payload with ID -> {} not found.".format(payload_id),
596
+ )
597
+
598
+
599
+ @app.put(path="/api/v1/payload/{payload_id}/up", tags=["payload"])
600
+ async def move_payload_item_up(
601
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # noqa: ARG001
602
+ payload_id: int,
603
+ ) -> dict:
604
+ """Move a payload item up in the list.
605
+
606
+ Args:
607
+ user: Annotated[models.User, Depends(auth.get_authorized_user)]
608
+ payload_id (int): payload item ID
609
+
610
+ Raises:
611
+ HTTPException: a payload item with the given ID couldn't be found
612
+
613
+ Returns:
614
+ dict: HTTP response
615
+
616
+ """
617
+
618
+ position = payload_list.move_payload_item_up(index=payload_id)
619
+
620
+ if position is None:
621
+ raise HTTPException(
622
+ status_code=404,
623
+ detail="Payload item with index -> {} is either out of range or is already on top of the payload list!".format(
624
+ payload_id,
625
+ ),
626
+ )
627
+
628
+ return {"result": {"new_position": position}}
629
+
630
+
631
+ @app.put(path="/api/v1/payload/{payload_id}/down", tags=["payload"])
632
+ async def move_payload_item_down(
633
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # noqa: ARG001
634
+ payload_id: int,
635
+ ) -> dict:
636
+ """Move a payload item down in the list.
637
+
638
+ Args:
639
+ user: Annotated[models.User, Depends(auth.get_authorized_user)]
640
+ payload_id (int):
641
+ The payload item ID.
642
+
643
+ Raises:
644
+ HTTPException: a payload item with the given ID couldn't be found
645
+
646
+ Returns:
647
+ dict: HTTP response
648
+
649
+ """
650
+
651
+ position = payload_list.move_payload_item_down(index=payload_id)
652
+
653
+ if position is None:
654
+ raise HTTPException(
655
+ status_code=404,
656
+ detail="Payload item with index -> {} is either out of range or is already on bottom of the payload list!".format(
657
+ payload_id,
658
+ ),
659
+ )
660
+
661
+ return {"result": {"new_position": position}}
662
+
663
+
664
+ @app.get(path="/api/v1/payload/{payload_id}/content", tags=["payload"])
665
+ async def get_payload_content(
666
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # noqa: ARG001
667
+ # pylint: disable=unused-argument
668
+ payload_id: int,
669
+ ) -> dict | None:
670
+ """Get a payload item based on its ID.
671
+
672
+ Args:
673
+ user: Annotated[models.User, Depends(auth.get_authorized_user)]
674
+ payload_id (int):
675
+ The payload item ID.
676
+
677
+ Raises:
678
+ HTTPException:
679
+ A payload item with the given ID couldn't be found.
680
+
681
+ Returns:
682
+ dict:
683
+ HTTP response.
684
+
685
+ """
686
+
687
+ data = payload_list.get_payload_item(index=payload_id)
688
+
689
+ if data is None:
690
+ raise HTTPException(
691
+ status_code=404,
692
+ detail="Payload with ID -> {} not found!".format(payload_id),
693
+ )
694
+
695
+ filename = data.filename
696
+
697
+ return load_payload(payload_source=filename)
698
+
699
+
700
+ @app.get(path="/api/v1/payload/{payload_id}/download", tags=["payload"])
701
+ def download_payload_content(
702
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # noqa: ARG001
703
+ payload_id: int,
704
+ ) -> FileResponse:
705
+ """Download the payload for a specific payload item."""
706
+
707
+ payload = payload_list.get_payload_item(index=payload_id)
708
+
709
+ if payload is None:
710
+ raise HTTPException(
711
+ status_code=404,
712
+ detail="Payload with ID -> {} not found!".format(payload_id),
713
+ )
714
+
715
+ if not os.path.isfile(payload.filename):
716
+ raise HTTPException(
717
+ status_code=404,
718
+ detail="Payload file -> '{}' not found".format(payload.filename),
719
+ )
720
+ with open(payload.filename, encoding="UTF-8") as file:
721
+ content = file.read()
722
+ return Response(
723
+ content,
724
+ media_type="application/octet-stream",
725
+ headers={
726
+ "Content-Disposition": f'attachment; filename="{os.path.basename(payload.filename)}"',
727
+ },
728
+ )
729
+
730
+
731
+ @app.get(path="/api/v1/payload/{payload_id}/log", tags=["payload"])
732
+ def download_payload_logfile(
733
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # noqa: ARG001
734
+ payload_id: int,
735
+ ) -> FileResponse:
736
+ """Download the logfile for a specific payload."""
737
+
738
+ payload = payload_list.get_payload_item(index=payload_id)
739
+
740
+ if payload is None:
741
+ raise HTTPException(status_code=404, detail="Payload not found")
742
+
743
+ filename = payload.logfile
744
+
745
+ if not os.path.isfile(filename):
746
+ raise HTTPException(
747
+ status_code=404,
748
+ detail="Log file -> '{}' not found".format(filename),
749
+ )
750
+ with open(filename, encoding="UTF-8") as file:
751
+ content = file.read()
752
+ return Response(
753
+ content,
754
+ media_type="application/octet-stream",
755
+ headers={
756
+ "Content-Disposition": f'attachment; filename="{os.path.basename(filename)}"',
757
+ },
758
+ )
759
+
760
+
761
+ def get_cshost() -> str:
762
+ """Get the cs_hostname from the environment Variable OTCS_PUBLIC_HOST otherwise read it from the otcs-frontend-configmap."""
763
+
764
+ if "OTCS_PUBLIC_URL" in os.environ:
765
+ return os.getenv("OTCS_PUBLIC_URL", "otcs")
766
+
767
+ else:
768
+ cm = app.k8s_object.get_config_map("otcs-frontend-configmap")
769
+
770
+ if cm is None:
771
+ raise HTTPException(
772
+ status_code=500,
773
+ detail=f"Could not read otcs-frontend-configmap from namespace: {app.k8s_object.get_namespace()}",
774
+ )
775
+
776
+ config_file = cm.data.get("config.yaml")
777
+ config = yaml.safe_load(config_file)
778
+
779
+ try:
780
+ cs_url = HttpUrl(config.get("csurl"))
781
+ except ValidationError as ve:
782
+ raise HTTPException(
783
+ status_code=500,
784
+ detail="Could not read otcs_host from environment variable OTCS_PULIBC_URL or configmap otcs-frontend-configmap/config.yaml/cs_url",
785
+ ) from ve
786
+ return cs_url.host
787
+
788
+
789
+ def __get_maintenance_mode_status() -> dict:
790
+ """Get status of maintenance mode.
791
+
792
+ Returns:
793
+ dict:
794
+ Details of maintenance mode.
795
+
796
+ """
797
+ ingress = app.k8s_object.get_ingress("otxecm-ingress")
798
+
799
+ if ingress is None:
800
+ raise HTTPException(
801
+ status_code=500,
802
+ detail="No ingress object found to read Maintenance Mode status",
803
+ )
804
+
805
+ enabled = False
806
+ for rule in ingress.spec.rules:
807
+ if rule.host == get_cshost():
808
+ enabled = rule.http.paths[0].backend.service.name != "otcs-frontend"
809
+
810
+ return {
811
+ "enabled": enabled,
812
+ "title": maint_settings.title,
813
+ "text": maint_settings.text,
814
+ "footer": maint_settings.footer,
815
+ }
816
+
817
+
818
+ @app.get(path="/api/v1/maintenance", tags=["maintenance"])
819
+ async def get_maintenance_mode_status(
820
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # noqa: ARG001
821
+ ) -> JSONResponse:
822
+ """Return status of maintenance mode.
823
+
824
+ Returns:
825
+ dict:
826
+ Details of maintenance mode.
827
+
828
+ """
829
+
830
+ return __get_maintenance_mode_status()
831
+
832
+
833
+ def set_maintenance_mode_via_ingress(enabled: bool) -> None:
834
+ """Set maintenance mode."""
835
+
836
+ app.logger.warning(
837
+ "Setting Maintenance Mode to -> %s",
838
+ (enabled),
839
+ )
840
+
841
+ if enabled:
842
+ app.k8s_object.update_ingress_backend_services(
843
+ "otxecm-ingress",
844
+ get_cshost(),
845
+ "otxecm-customizer",
846
+ 5555,
847
+ )
848
+ else:
849
+ app.k8s_object.update_ingress_backend_services(
850
+ "otxecm-ingress",
851
+ get_cshost(),
852
+ "otcs-frontend",
853
+ 80,
854
+ )
855
+
856
+
857
+ @app.post(path="/api/v1/maintenance", tags=["maintenance"])
858
+ async def set_maintenance_mode_options(
859
+ user: Annotated[models.User, Depends(auth.get_authorized_user)], # pylint: disable=unused-argument # noqa: ARG001
860
+ enabled: Annotated[bool, Form()],
861
+ title: Annotated[str | None, Form()] = "",
862
+ text: Annotated[str | None, Form()] = "",
863
+ footer: Annotated[str | None, Form()] = "",
864
+ ) -> dict:
865
+ """Configure the Maintenance Mode and set options.
866
+
867
+ Args:
868
+ user (models.User):
869
+ Added to enforce authentication requirement
870
+ enabled (bool, optional):
871
+ Enable or disable the maintenance mode to allow access to the OTCS Frontend.
872
+ title (Optional[str], optional):
873
+ Title for the Maintenance Page.
874
+ text (Optional[str], optional):
875
+ Text for the Maintenance Page.
876
+ footer (Optional[str], optional):
877
+ Text for the Footer of the Maintenance Page.
878
+
879
+ Returns:
880
+ dict: _description_
881
+
882
+ """
883
+ # Enable / Disable the acutual Maintenance Mode
884
+ set_maintenance_mode_via_ingress(enabled)
885
+
886
+ if title is not None and title != "":
887
+ maint_settings.title = title
888
+
889
+ if text is not None and text != "":
890
+ maint_settings.text = text
891
+
892
+ if footer is not None:
893
+ maint_settings.footer = footer
894
+
895
+ return __get_maintenance_mode_status()
896
+
897
+
898
+ @app.get("/api/shutdown", include_in_schema=False)
899
+ def shutdown(user: Annotated[models.User, Depends(auth.get_authorized_user)]) -> JSONResponse:
900
+ """Endpoint to end the application."""
901
+
902
+ app.logger.warning(
903
+ "Shutting down the API - Requested via api by user -> %s",
904
+ user.id,
905
+ )
906
+ os.kill(os.getpid(), signal.SIGTERM)
907
+
908
+ return JSONResponse()
909
+
910
+
911
+ def run_api() -> None:
912
+ """Start the FASTAPI Webserver."""
913
+
914
+ uvicorn.run("pyxecm.customizer.api:app", host=api_settings.bind_address, port=api_settings.bind_port)