assets.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860
  1. from __future__ import annotations
  2. import json
  3. from humanize import naturaldelta
  4. from flask import current_app, request
  5. from flask_classful import FlaskView, route
  6. from flask_login import current_user
  7. from flask_security import auth_required
  8. from flask_json import as_json
  9. from flask_sqlalchemy.pagination import SelectPagination
  10. from marshmallow import fields
  11. import marshmallow.validate as validate
  12. from webargs.flaskparser import use_kwargs, use_args
  13. from sqlalchemy import select, delete, func, or_
  14. from flexmeasures.data.services.sensors import (
  15. build_asset_jobs_data,
  16. )
  17. from flexmeasures.data.services.job_cache import NoRedisConfigured
  18. from flexmeasures.auth.decorators import permission_required_for_context
  19. from flexmeasures.data import db
  20. from flexmeasures.data.models.user import Account
  21. from flexmeasures.data.models.audit_log import AssetAuditLog
  22. from flexmeasures.data.models.generic_assets import GenericAsset
  23. from flexmeasures.data.queries.generic_assets import query_assets_by_search_terms
  24. from flexmeasures.data.schemas import AwareDateTimeField
  25. from flexmeasures.data.schemas.generic_assets import GenericAssetSchema as AssetSchema
  26. from flexmeasures.api.common.schemas.generic_assets import AssetIdField
  27. from flexmeasures.api.common.schemas.search import SearchFilterField
  28. from flexmeasures.api.common.schemas.users import AccountIdField
  29. from flexmeasures.utils.coding_utils import flatten_unique
  30. from flexmeasures.ui.utils.view_utils import clear_session, set_session_variables
  31. from flexmeasures.auth.policy import check_access
  32. from werkzeug.exceptions import Forbidden, Unauthorized
  33. from flexmeasures.data.schemas.sensors import SensorSchema
  34. from flexmeasures.data.models.time_series import Sensor
  35. from flexmeasures.data.schemas.scheduling import DBFlexContextSchema
  36. from flexmeasures.utils.time_utils import naturalized_datetime_str
  37. asset_schema = AssetSchema()
  38. assets_schema = AssetSchema(many=True)
  39. sensor_schema = SensorSchema()
  40. sensors_schema = SensorSchema(many=True)
  41. partial_asset_schema = AssetSchema(partial=True, exclude=["account_id"])
  42. def get_accessible_accounts() -> list[Account]:
  43. accounts = []
  44. for _account in db.session.scalars(select(Account)).all():
  45. try:
  46. check_access(_account, "read")
  47. accounts.append(_account)
  48. except (Forbidden, Unauthorized):
  49. pass
  50. return accounts
  51. class AssetAPI(FlaskView):
  52. """
  53. This API view exposes generic assets.
  54. """
  55. route_base = "/assets"
  56. trailing_slash = False
  57. decorators = [auth_required()]
  58. @route("", methods=["GET"])
  59. @use_kwargs(
  60. {
  61. "account": AccountIdField(data_key="account_id", load_default=None),
  62. "all_accessible": fields.Bool(
  63. data_key="all_accessible", load_default=False
  64. ),
  65. "include_public": fields.Bool(
  66. data_key="include_public", load_default=False
  67. ),
  68. "page": fields.Int(
  69. required=False, validate=validate.Range(min=1), load_default=None
  70. ),
  71. "per_page": fields.Int(
  72. required=False, validate=validate.Range(min=1), load_default=10
  73. ),
  74. "filter": SearchFilterField(required=False, load_default=None),
  75. "sort_by": fields.Str(
  76. required=False,
  77. load_default=None,
  78. validate=validate.OneOf(["id", "name", "owner"]),
  79. ),
  80. "sort_dir": fields.Str(
  81. required=False,
  82. load_default=None,
  83. validate=validate.OneOf(["asc", "desc"]),
  84. ),
  85. },
  86. location="query",
  87. )
  88. @as_json
  89. def index(
  90. self,
  91. account: Account | None,
  92. all_accessible: bool,
  93. include_public: bool,
  94. page: int | None = None,
  95. per_page: int | None = None,
  96. filter: list[str] | None = None,
  97. sort_by: str | None = None,
  98. sort_dir: str | None = None,
  99. ):
  100. """List all assets owned by user's accounts, or a certain account or all accessible accounts.
  101. .. :quickref: Asset; Download asset list
  102. This endpoint returns all accessible assets by accounts.
  103. The `account_id` query parameter can be used to list assets from any account (if the user is allowed to read them). Per default, the user's account is used.
  104. Alternatively, the `all_accessible` query parameter can be used to list assets from all accounts the current_user has read-access to, plus all public assets. Defaults to `false`.
  105. The `include_public` query parameter can be used to include public assets in the response. Defaults to `false`.
  106. The endpoint supports pagination of the asset list using the `page` and `per_page` query parameters.
  107. - If the `page` parameter is not provided, all assets are returned, without pagination information. The result will be a list of assets.
  108. - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per_page` (default is 10).
  109. - If a search 'filter' such as 'solar "ACME corp"' is provided, the response will filter out assets where each search term is either present in their name or account name.
  110. The response schema for pagination is inspired by https://datatables.net/manual/server-side#Returned-data
  111. **Example response**
  112. An example of one asset being returned in a paginated response:
  113. .. sourcecode:: json
  114. {
  115. "data" : [
  116. {
  117. "id": 1,
  118. "name": "Test battery",
  119. "latitude": 10,
  120. "longitude": 100,
  121. "account_id": 2,
  122. "generic_asset_type": {"id": 1, "name": "battery"}
  123. }
  124. ],
  125. "num-records" : 1,
  126. "filtered-records" : 1
  127. }
  128. If no pagination is requested, the response only consists of the list under the "data" key.
  129. :reqheader Authorization: The authentication token
  130. :reqheader Content-Type: application/json
  131. :resheader Content-Type: application/json
  132. :status 200: PROCESSED
  133. :status 400: INVALID_REQUEST
  134. :status 401: UNAUTHORIZED
  135. :status 403: INVALID_SENDER
  136. :status 422: UNPROCESSABLE_ENTITY
  137. """
  138. # find out which accounts are relevant
  139. if all_accessible:
  140. accounts = get_accessible_accounts()
  141. else:
  142. if account is None:
  143. account = current_user.account
  144. check_access(account, "read")
  145. accounts = [account]
  146. filter_statement = GenericAsset.account_id.in_([a.id for a in accounts])
  147. # add public assets if the request asks for all the accessible assets
  148. if all_accessible or include_public:
  149. filter_statement = filter_statement | GenericAsset.account_id.is_(None)
  150. query = query_assets_by_search_terms(
  151. search_terms=filter,
  152. filter_statement=filter_statement,
  153. sort_by=sort_by,
  154. sort_dir=sort_dir,
  155. )
  156. if page is None:
  157. response = asset_schema.dump(db.session.scalars(query).all(), many=True)
  158. else:
  159. if per_page is None:
  160. per_page = 10
  161. select_pagination: SelectPagination = db.paginate(
  162. query, per_page=per_page, page=page
  163. )
  164. num_records = db.session.scalar(
  165. select(func.count(GenericAsset.id)).filter(filter_statement)
  166. )
  167. response = {
  168. "data": asset_schema.dump(select_pagination.items, many=True),
  169. "num-records": num_records,
  170. "filtered-records": select_pagination.total,
  171. }
  172. return response, 200
  173. @route(
  174. "/<id>/sensors",
  175. methods=["GET"],
  176. )
  177. @use_kwargs(
  178. {
  179. "asset": AssetIdField(data_key="id"),
  180. },
  181. location="path",
  182. )
  183. @use_kwargs(
  184. {
  185. "page": fields.Int(
  186. required=False, validate=validate.Range(min=1), dump_default=1
  187. ),
  188. "per_page": fields.Int(
  189. required=False, validate=validate.Range(min=1), dump_default=10
  190. ),
  191. "filter": SearchFilterField(required=False, load_default=None),
  192. "sort_by": fields.Str(
  193. required=False,
  194. load_default=None,
  195. validate=validate.OneOf(["id", "name", "resolution"]),
  196. ),
  197. "sort_dir": fields.Str(
  198. required=False,
  199. load_default=None,
  200. validate=validate.OneOf(["asc", "desc"]),
  201. ),
  202. },
  203. location="query",
  204. )
  205. @as_json
  206. def asset_sensors(
  207. self,
  208. id: int,
  209. asset: GenericAsset | None,
  210. page: int | None = None,
  211. per_page: int | None = None,
  212. filter: list[str] | None = None,
  213. sort_by: str | None = None,
  214. sort_dir: str | None = None,
  215. ):
  216. """
  217. List all sensors under an asset.
  218. .. :quickref: Asset; Return all sensors under an asset.
  219. This endpoint returns all sensors under an asset.
  220. The endpoint supports pagination of the asset list using the `page` and `per_page` query parameters.
  221. - If the `page` parameter is not provided, all sensors are returned, without pagination information. The result will be a list of sensors.
  222. - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per_page` (default is 10).
  223. The response schema for pagination is inspired by https://datatables.net/manual/server-side#Returned-data
  224. **Example response**
  225. An example of one asset being returned in a paginated response:
  226. .. sourcecode:: json
  227. {
  228. "data" : [
  229. {
  230. "id": 1,
  231. "name": "Test battery",
  232. "latitude": 10,
  233. "longitude": 100,
  234. "account_id": 2,
  235. "generic_asset_type": {"id": 1, "name": "battery"}
  236. }
  237. ],
  238. "num-records" : 1,
  239. "filtered-records" : 1
  240. }
  241. If no pagination is requested, the response only consists of the list under the "data" key.
  242. :reqheader Authorization: The authentication token
  243. :reqheader Content-Type: application/json
  244. :resheader Content-Type: application/json
  245. :status 200: PROCESSED
  246. :status 400: INVALID_REQUEST
  247. :status 401: UNAUTHORIZED
  248. :status 403: INVALID_SENDER
  249. :status 422: UNPROCESSABLE_ENTITY
  250. """
  251. query_statement = Sensor.generic_asset_id == asset.id
  252. query = select(Sensor).filter(query_statement)
  253. if filter:
  254. search_terms = filter[0].split(" ")
  255. query = query.filter(
  256. or_(*[Sensor.name.ilike(f"%{term}%") for term in search_terms])
  257. )
  258. if sort_by is not None and sort_dir is not None:
  259. valid_sort_columns = {
  260. "id": Sensor.id,
  261. "name": Sensor.name,
  262. "resolution": Sensor.event_resolution,
  263. }
  264. query = query.order_by(
  265. valid_sort_columns[sort_by].asc()
  266. if sort_dir == "asc"
  267. else valid_sort_columns[sort_by].desc()
  268. )
  269. select_pagination: SelectPagination = db.paginate(
  270. query, per_page=per_page, page=page
  271. )
  272. num_records = db.session.scalar(
  273. select(func.count(Sensor.id)).where(query_statement)
  274. )
  275. sensors_response: list = [
  276. {
  277. **sensor_schema.dump(sensor),
  278. "event_resolution": naturaldelta(sensor.event_resolution),
  279. }
  280. for sensor in select_pagination.items
  281. ]
  282. response = {
  283. "data": sensors_response,
  284. "num-records": num_records,
  285. "filtered-records": select_pagination.total,
  286. }
  287. return response, 200
  288. @route("/public", methods=["GET"])
  289. @as_json
  290. def public(self):
  291. """Return all public assets.
  292. .. :quickref: Asset; Return all public assets.
  293. This endpoint returns all public assets.
  294. :reqheader Authorization: The authentication token
  295. :reqheader Content-Type: application/json
  296. :resheader Content-Type: application/json
  297. :status 200: PROCESSED
  298. :status 400: INVALID_REQUEST
  299. :status 401: UNAUTHORIZED
  300. :status 422: UNPROCESSABLE_ENTITY
  301. """
  302. assets = db.session.scalars(
  303. select(GenericAsset).filter(GenericAsset.account_id.is_(None))
  304. ).all()
  305. return assets_schema.dump(assets), 200
  306. @route("", methods=["POST"])
  307. @permission_required_for_context(
  308. "create-children", ctx_loader=AccountIdField.load_current
  309. )
  310. @use_args(asset_schema)
  311. def post(self, asset_data: dict):
  312. """Create new asset.
  313. .. :quickref: Asset; Create a new asset
  314. This endpoint creates a new asset.
  315. **Example request**
  316. .. sourcecode:: json
  317. {
  318. "name": "Test battery",
  319. "generic_asset_type_id": 2,
  320. "account_id": 2,
  321. "latitude": 40,
  322. "longitude": 170.3,
  323. }
  324. The newly posted asset is returned in the response.
  325. :reqheader Authorization: The authentication token
  326. :reqheader Content-Type: application/json
  327. :resheader Content-Type: application/json
  328. :status 201: CREATED
  329. :status 400: INVALID_REQUEST
  330. :status 401: UNAUTHORIZED
  331. :status 403: INVALID_SENDER
  332. :status 422: UNPROCESSABLE_ENTITY
  333. """
  334. asset = GenericAsset(**asset_data)
  335. db.session.add(asset)
  336. # assign asset id
  337. db.session.flush()
  338. db.session.commit()
  339. AssetAuditLog.add_record(asset, f"Created asset '{asset.name}': {asset.id}")
  340. return asset_schema.dump(asset), 201
  341. @route("/<id>", methods=["GET"])
  342. @use_kwargs({"asset": AssetIdField(data_key="id")}, location="path")
  343. @permission_required_for_context("read", ctx_arg_name="asset")
  344. @as_json
  345. def fetch_one(self, id, asset):
  346. """Fetch a given asset.
  347. .. :quickref: Asset; Get an asset
  348. This endpoint gets an asset.
  349. **Example response**
  350. .. sourcecode:: json
  351. {
  352. "generic_asset_type_id": 2,
  353. "name": "Test battery",
  354. "id": 1,
  355. "latitude": 10,
  356. "longitude": 100,
  357. "account_id": 1,
  358. }
  359. :reqheader Authorization: The authentication token
  360. :reqheader Content-Type: application/json
  361. :resheader Content-Type: application/json
  362. :status 200: PROCESSED
  363. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  364. :status 401: UNAUTHORIZED
  365. :status 403: INVALID_SENDER
  366. :status 422: UNPROCESSABLE_ENTITY
  367. """
  368. return asset_schema.dump(asset), 200
  369. @route("/<id>", methods=["PATCH"])
  370. @use_args(partial_asset_schema)
  371. @use_kwargs({"db_asset": AssetIdField(data_key="id")}, location="path")
  372. @permission_required_for_context("update", ctx_arg_name="db_asset")
  373. @as_json
  374. def patch(self, asset_data: dict, id: int, db_asset: GenericAsset):
  375. """Update an asset given its identifier.
  376. .. :quickref: Asset; Update an asset
  377. This endpoint sets data for an existing asset.
  378. Any subset of asset fields can be sent.
  379. The following fields are not allowed to be updated:
  380. - id
  381. - account_id
  382. **Example request**
  383. .. sourcecode:: json
  384. {
  385. "latitude": 11.1,
  386. "longitude": 99.9,
  387. }
  388. **Example response**
  389. The whole asset is returned in the response:
  390. .. sourcecode:: json
  391. {
  392. "generic_asset_type_id": 2,
  393. "id": 1,
  394. "latitude": 11.1,
  395. "longitude": 99.9,
  396. "name": "Test battery",
  397. "account_id": 2,
  398. }
  399. :reqheader Authorization: The authentication token
  400. :reqheader Content-Type: application/json
  401. :resheader Content-Type: application/json
  402. :status 200: UPDATED
  403. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  404. :status 401: UNAUTHORIZED
  405. :status 403: INVALID_SENDER
  406. :status 422: UNPROCESSABLE_ENTITY
  407. """
  408. audit_log_data = list()
  409. for k, v in asset_data.items():
  410. if getattr(db_asset, k) == v:
  411. continue
  412. if k == "attributes":
  413. current_attributes = getattr(db_asset, k)
  414. for attr_key, attr_value in v.items():
  415. if current_attributes.get(attr_key) != attr_value:
  416. audit_log_data.append(
  417. f"Updated Attr: {attr_key}, From: {current_attributes.get(attr_key)}, To: {attr_value}"
  418. )
  419. continue
  420. if k == "flex_context":
  421. try:
  422. # Validate the flex context schema
  423. DBFlexContextSchema().load(v)
  424. except Exception as e:
  425. return {"error": str(e)}, 422
  426. audit_log_data.append(
  427. f"Updated Field: {k}, From: {getattr(db_asset, k)}, To: {v}"
  428. )
  429. # Iterate over each field or attribute updates and create a separate audit log entry for each.
  430. for event in audit_log_data:
  431. AssetAuditLog.add_record(db_asset, event)
  432. for k, v in asset_data.items():
  433. setattr(db_asset, k, v)
  434. db.session.add(db_asset)
  435. db.session.commit()
  436. return asset_schema.dump(db_asset), 200
  437. @route("/<id>", methods=["DELETE"])
  438. @use_kwargs({"asset": AssetIdField(data_key="id")}, location="path")
  439. @permission_required_for_context("delete", ctx_arg_name="asset")
  440. @as_json
  441. def delete(self, id: int, asset: GenericAsset):
  442. """Delete an asset given its identifier.
  443. .. :quickref: Asset; Delete an asset
  444. This endpoint deletes an existing asset, as well as all sensors and measurements recorded for it.
  445. :reqheader Authorization: The authentication token
  446. :reqheader Content-Type: application/json
  447. :resheader Content-Type: application/json
  448. :status 204: DELETED
  449. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  450. :status 401: UNAUTHORIZED
  451. :status 403: INVALID_SENDER
  452. :status 422: UNPROCESSABLE_ENTITY
  453. """
  454. asset_name, asset_id = asset.name, asset.id
  455. AssetAuditLog.add_record(asset, f"Deleted asset '{asset_name}': {asset_id}")
  456. db.session.execute(delete(GenericAsset).filter_by(id=asset.id))
  457. db.session.commit()
  458. current_app.logger.info("Deleted asset '%s'." % asset_name)
  459. return {}, 204
  460. @route("/<id>/chart", strict_slashes=False) # strict on next version? see #1014
  461. @use_kwargs(
  462. {"asset": AssetIdField(data_key="id")},
  463. location="path",
  464. )
  465. @use_kwargs(
  466. {
  467. "event_starts_after": AwareDateTimeField(format="iso", required=False),
  468. "event_ends_before": AwareDateTimeField(format="iso", required=False),
  469. "beliefs_after": AwareDateTimeField(format="iso", required=False),
  470. "beliefs_before": AwareDateTimeField(format="iso", required=False),
  471. "include_data": fields.Boolean(required=False),
  472. "combine_legend": fields.Boolean(required=False, load_default=True),
  473. "dataset_name": fields.Str(required=False),
  474. "height": fields.Str(required=False),
  475. "width": fields.Str(required=False),
  476. },
  477. location="query",
  478. )
  479. @permission_required_for_context("read", ctx_arg_name="asset")
  480. def get_chart(self, id: int, asset: GenericAsset, **kwargs):
  481. """GET from /assets/<id>/chart
  482. .. :quickref: Chart; Download a chart with time series
  483. """
  484. # Store selected time range as session variables, for a consistent UX across UI page loads
  485. set_session_variables("event_starts_after", "event_ends_before")
  486. return json.dumps(asset.chart(**kwargs))
  487. @route(
  488. "/<id>/chart_data", strict_slashes=False
  489. ) # strict on next version? see #1014
  490. @use_kwargs(
  491. {"asset": AssetIdField(data_key="id")},
  492. location="path",
  493. )
  494. @use_kwargs(
  495. {
  496. "event_starts_after": AwareDateTimeField(format="iso", required=False),
  497. "event_ends_before": AwareDateTimeField(format="iso", required=False),
  498. "beliefs_after": AwareDateTimeField(format="iso", required=False),
  499. "beliefs_before": AwareDateTimeField(format="iso", required=False),
  500. "most_recent_beliefs_only": fields.Boolean(required=False),
  501. },
  502. location="query",
  503. )
  504. @permission_required_for_context("read", ctx_arg_name="asset")
  505. def get_chart_data(self, id: int, asset: GenericAsset, **kwargs):
  506. """GET from /assets/<id>/chart_data
  507. .. :quickref: Chart; Download time series for use in charts
  508. Data for use in charts (in case you have the chart specs already).
  509. """
  510. sensors = flatten_unique(asset.validate_sensors_to_show())
  511. return asset.search_beliefs(sensors=sensors, as_json=True, **kwargs)
  512. @route("/<id>/auditlog")
  513. @use_kwargs(
  514. {"asset": AssetIdField(data_key="id")},
  515. location="path",
  516. )
  517. @permission_required_for_context("read", ctx_arg_name="asset")
  518. @use_kwargs(
  519. {
  520. "page": fields.Int(
  521. required=False, validate=validate.Range(min=1), load_default=1
  522. ),
  523. "per_page": fields.Int(
  524. required=False, validate=validate.Range(min=1), load_default=10
  525. ),
  526. "filter": SearchFilterField(required=False, load_default=None),
  527. "sort_by": fields.Str(
  528. required=False,
  529. load_default=None,
  530. validate=validate.OneOf(["event_datetime"]),
  531. ),
  532. "sort_dir": fields.Str(
  533. required=False,
  534. load_default=None,
  535. validate=validate.OneOf(["asc", "desc"]),
  536. ),
  537. },
  538. location="query",
  539. )
  540. @as_json
  541. def auditlog(
  542. self,
  543. id: int,
  544. asset: GenericAsset,
  545. page: int | None = None,
  546. per_page: int | None = None,
  547. filter: list[str] | None = None,
  548. sort_by: str | None = None,
  549. sort_dir: str | None = None,
  550. ):
  551. """API endpoint to get history of asset related actions.
  552. The endpoint is paginated and supports search filters.
  553. - If the `page` parameter is not provided, all audit logs are returned paginated by `per_page` (default is 10).
  554. - If a `page` parameter is provided, the response will be paginated, showing a specific number of assets per page as defined by `per_page` (default is 10).
  555. - If a search 'filter' is provided, the response will filter out audit logs where each search term is either present in the event or active user name.
  556. The response schema for pagination is inspired by https://datatables.net/manual/server-side
  557. **Example response**
  558. .. sourcecode:: json
  559. {
  560. "data" : [
  561. {
  562. 'event': 'Asset test asset deleted',
  563. 'event_datetime': '2021-01-01T00:00:00',
  564. 'active_user_name': 'Test user',
  565. }
  566. ],
  567. "num-records" : 1,
  568. "filtered-records" : 1
  569. }
  570. :reqheader Authorization: The authentication token
  571. :reqheader Content-Type: application/json
  572. :resheader Content-Type: application/json
  573. :status 200: PROCESSED
  574. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  575. :status 401: UNAUTHORIZED
  576. :status 403: INVALID_SENDER
  577. :status 422: UNPROCESSABLE_ENTITY
  578. """
  579. query_statement = AssetAuditLog.affected_asset_id == asset.id
  580. query = select(AssetAuditLog).filter(query_statement)
  581. if filter:
  582. search_terms = filter[0].split(" ")
  583. query = query.filter(
  584. or_(
  585. *[AssetAuditLog.event.ilike(f"%{term}%") for term in search_terms],
  586. *[
  587. AssetAuditLog.active_user_name.ilike(f"%{term}%")
  588. for term in search_terms
  589. ],
  590. )
  591. )
  592. if sort_by is not None and sort_dir is not None:
  593. valid_sort_columns = {"event_datetime": AssetAuditLog.event_datetime}
  594. query = query.order_by(
  595. valid_sort_columns[sort_by].asc()
  596. if sort_dir == "asc"
  597. else valid_sort_columns[sort_by].desc()
  598. )
  599. select_pagination: SelectPagination = db.paginate(
  600. query, per_page=per_page, page=page
  601. )
  602. num_records = db.session.scalar(
  603. select(func.count(AssetAuditLog.id)).where(query_statement)
  604. )
  605. audit_logs_response: list = [
  606. {
  607. "event": audit_log.event,
  608. "event_datetime": naturalized_datetime_str(audit_log.event_datetime),
  609. "active_user_name": audit_log.active_user_name,
  610. "active_user_id": audit_log.active_user_id,
  611. }
  612. for audit_log in select_pagination.items
  613. ]
  614. response = {
  615. "data": audit_logs_response,
  616. "num-records": num_records,
  617. "filtered-records": select_pagination.total,
  618. }
  619. return response, 200
  620. @route("/<id>/jobs", methods=["GET"])
  621. @use_kwargs(
  622. {"asset": AssetIdField(data_key="id")},
  623. location="path",
  624. )
  625. @permission_required_for_context("read", ctx_arg_name="asset")
  626. @as_json
  627. def get_jobs(self, id: int, asset: GenericAsset):
  628. """API endpoint to get the jobs of an asset.
  629. This endpoint returns all jobs of an asset.
  630. The response will be a list of jobs.
  631. **Example response**
  632. .. sourcecode:: json
  633. {
  634. "jobs": [
  635. {
  636. "job_id": 1,
  637. "queue": "scheduling",
  638. "asset_or_sensor_type": "asset",
  639. "asset_id": 1,
  640. "status": "finished",
  641. "err": None,
  642. "enqueued_at": "2023-10-01T00:00:00",
  643. "metadata_hash": "abc123",
  644. }
  645. ],
  646. "redis_connection_err": null
  647. }
  648. :reqheader Authorization: The authentication token
  649. :reqheader Content-Type: application/json
  650. :resheader Content-Type: application/json
  651. :status 200: PROCESSED
  652. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  653. :status 401: UNAUTHORIZED
  654. :status 403: INVALID_SENDER
  655. :status 422: UNPROCESSABLE_ENTITY
  656. """
  657. redis_connection_err = None
  658. all_jobs_data = list()
  659. try:
  660. jobs_data = build_asset_jobs_data(asset)
  661. except NoRedisConfigured as e:
  662. redis_connection_err = e.args[0]
  663. else:
  664. all_jobs_data = jobs_data
  665. return {
  666. "jobs": all_jobs_data,
  667. "redis_connection_err": redis_connection_err,
  668. }, 200
  669. @route("/default_asset_view", methods=["POST"])
  670. @as_json
  671. @use_kwargs(
  672. {
  673. "default_asset_view": fields.Str(
  674. required=True,
  675. validate=validate.OneOf(
  676. [
  677. "Audit Log",
  678. "Context",
  679. "Graphs",
  680. "Properties",
  681. "Status",
  682. ]
  683. ),
  684. ),
  685. "use_as_default": fields.Bool(required=False, load_default=True),
  686. },
  687. location="json",
  688. )
  689. def update_default_asset_view(self, **kwargs):
  690. """Update the default asset view for the current user session.
  691. .. :quickref: Asset; Update the default asset view
  692. **Example request**
  693. .. sourcecode:: json
  694. {
  695. "default_asset_view": "Graphs",
  696. "use_as_default": true
  697. }
  698. **Example response**
  699. .. sourcecode:: json
  700. {
  701. "message": "Default asset view updated successfully."
  702. }
  703. This endpoint sets the default asset view for the current user session if use_as_default is true.
  704. If use_as_default is false, it clears the session variable for the default asset view.
  705. :reqheader Authorization: The authentication token
  706. :reqheader Content-Type: application/json
  707. :resheader Content-Type: application/json
  708. :status 200: PROCESSED
  709. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  710. :status 401: UNAUTHORIZED
  711. :status 403: INVALID_SENDER
  712. :status 422: UNPROCESSABLE_ENTITY
  713. """
  714. # Update the request.values
  715. request_values = request.values.copy()
  716. request_values.update(kwargs)
  717. request.values = request_values
  718. use_as_default = kwargs.get("use_as_default", True)
  719. if use_as_default:
  720. # Set the default asset view for the current user session
  721. set_session_variables(
  722. "default_asset_view",
  723. )
  724. else:
  725. # Remove the default asset view from the session
  726. clear_session(keys_to_clear=["default_asset_view"])
  727. return {
  728. "message": "Default asset view updated successfully.",
  729. }, 200