views.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. from __future__ import annotations
  2. from flask import redirect, url_for, current_app, request, session
  3. from flask_classful import FlaskView, route
  4. from flask_security import login_required, current_user
  5. from webargs.flaskparser import use_kwargs
  6. from flexmeasures.auth.error_handling import unauthorized_handler
  7. from flexmeasures.data import db
  8. from flexmeasures.auth.policy import check_access
  9. from flexmeasures.data.schemas import StartEndTimeSchema
  10. from flexmeasures.data.models.generic_assets import (
  11. GenericAsset,
  12. get_center_location_of_assets,
  13. )
  14. from flexmeasures.ui.utils.view_utils import ICON_MAPPING
  15. from flexmeasures.data.models.user import Account
  16. from flexmeasures.ui.utils.view_utils import render_flexmeasures_template
  17. from flexmeasures.ui.views.api_wrapper import InternalApi
  18. from flexmeasures.ui.views.assets.forms import NewAssetForm, AssetForm
  19. from flexmeasures.ui.views.assets.utils import (
  20. get_asset_by_id_or_raise_notfound,
  21. process_internal_api_response,
  22. user_can_create_assets,
  23. user_can_delete,
  24. user_can_update,
  25. get_list_assets_chart,
  26. add_child_asset,
  27. )
  28. from flexmeasures.data.services.sensors import (
  29. get_asset_sensors_metadata,
  30. )
  31. from flexmeasures.ui.utils.view_utils import available_units
  32. """
  33. Asset crud view.
  34. """
  35. class AssetCrudUI(FlaskView):
  36. """
  37. These views help us offer a Jinja2-based UI.
  38. If endpoints create/change data, we aim to use the logic and authorization in the actual API,
  39. so these views simply call the API functions,and deal with the response.
  40. """
  41. route_base = "/assets"
  42. trailing_slash = False
  43. @login_required
  44. def index(self, msg="", **kwargs):
  45. """GET from /assets
  46. List the user's assets. For admins, list across all accounts.
  47. """
  48. return render_flexmeasures_template(
  49. "assets/assets.html",
  50. asset_icon_map=ICON_MAPPING,
  51. message=msg,
  52. account=None,
  53. user_can_create_assets=user_can_create_assets(),
  54. )
  55. @login_required
  56. def owned_by(self, account_id: str):
  57. """/assets/owned_by/<account_id>"""
  58. msg = ""
  59. get_assets_response = InternalApi().get(
  60. url_for("AssetAPI:index"),
  61. query={"account_id": account_id},
  62. do_not_raise_for=[404],
  63. )
  64. if get_assets_response.status_code == 404:
  65. assets = []
  66. msg = f"Account {account_id} unknown."
  67. else:
  68. assets = [
  69. process_internal_api_response(ad, make_obj=True)
  70. for ad in get_assets_response.json()
  71. ]
  72. db.session.flush()
  73. return render_flexmeasures_template(
  74. "assets/assets.html",
  75. account=db.session.get(Account, account_id),
  76. assets=assets,
  77. msg=msg,
  78. user_can_create_assets=user_can_create_assets(),
  79. )
  80. @login_required
  81. def get(self, id: str, **kwargs):
  82. """/assets/<id>"""
  83. """
  84. This is a kind of utility view that redirects to the default asset view, either Context or the one saved in the user session.
  85. """
  86. default_asset_view = session.get("default_asset_view", "Context")
  87. return redirect(
  88. url_for(
  89. "AssetCrudUI:{}".format(default_asset_view.replace(" ", "").lower()),
  90. id=id,
  91. **kwargs,
  92. )
  93. )
  94. @login_required
  95. @route("/<id>/context")
  96. def context(self, id: str, **kwargs):
  97. """/assets/<id>/context"""
  98. # Get default asset view
  99. if id == "new": # show empty asset creation form
  100. parent_asset_id = request.args.get("parent_asset_id", "")
  101. if not user_can_create_assets():
  102. return unauthorized_handler(None, [])
  103. asset_form = NewAssetForm()
  104. asset_form.with_options()
  105. parent_asset_name = ""
  106. account = None
  107. if parent_asset_id:
  108. parent_asset = db.session.get(GenericAsset, parent_asset_id)
  109. if parent_asset:
  110. asset_form.account_id.data = str(
  111. parent_asset.account_id
  112. ) # Pre-set account
  113. parent_asset_name = parent_asset.name
  114. account = parent_asset.account_id
  115. return render_flexmeasures_template(
  116. "assets/asset_new.html",
  117. asset_form=asset_form,
  118. msg="",
  119. map_center=get_center_location_of_assets(user=current_user),
  120. mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""),
  121. parent_asset_name=parent_asset_name,
  122. parent_asset_id=parent_asset_id,
  123. account=account,
  124. )
  125. # show existing asset
  126. asset = get_asset_by_id_or_raise_notfound(id)
  127. check_access(asset, "read")
  128. assets = get_list_assets_chart(asset, base_asset=asset)
  129. assets = add_child_asset(asset, assets)
  130. current_asset_sensors = [
  131. {
  132. "name": sensor.name,
  133. "unit": sensor.unit,
  134. "link": url_for("SensorUI:get", id=sensor.id),
  135. }
  136. for sensor in asset.sensors
  137. ]
  138. return render_flexmeasures_template(
  139. "assets/asset_context.html",
  140. assets=assets,
  141. asset=asset,
  142. current_asset_sensors=current_asset_sensors,
  143. mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""),
  144. current_page="Context",
  145. available_units=available_units(),
  146. )
  147. @login_required
  148. @route("/<id>/sensor/new")
  149. def create_sensor(self, id: str):
  150. """GET to /assets/<id>/sensor/new"""
  151. asset = get_asset_by_id_or_raise_notfound(id)
  152. check_access(asset, "create-children")
  153. return render_flexmeasures_template(
  154. "assets/sensor_new.html",
  155. asset=asset,
  156. available_units=available_units(),
  157. )
  158. @login_required
  159. @route("/<id>/status")
  160. def status(self, id: str):
  161. """GET from /assets/<id>/status to show the staleness of the asset's sensors."""
  162. asset = get_asset_by_id_or_raise_notfound(id)
  163. check_access(asset, "read")
  164. status_data = get_asset_sensors_metadata(asset)
  165. return render_flexmeasures_template(
  166. "sensors/status.html",
  167. asset=asset,
  168. sensors=status_data,
  169. current_page="Status",
  170. )
  171. @login_required
  172. def post(self, id: str):
  173. """POST to /assets/<id>, where id can be 'create' (and thus a new asset is made from POST data)
  174. Most of the code deals with creating a user for the asset if no existing is chosen.
  175. """
  176. asset: GenericAsset = None
  177. error_msg = ""
  178. if id == "create":
  179. asset_form = NewAssetForm()
  180. asset_form.with_options()
  181. account, account_error = asset_form.set_account()
  182. asset_type, asset_type_error = asset_form.set_asset_type()
  183. form_valid = asset_form.validate_on_submit()
  184. # Fill up the form with useful errors for the user
  185. if account_error is not None:
  186. form_valid = False
  187. asset_form.account_id.errors.append(account_error)
  188. if asset_type_error is not None:
  189. form_valid = False
  190. asset_form.generic_asset_type_id.errors.append(asset_type_error)
  191. # Create new asset or return the form for new assets with a message
  192. if form_valid and asset_type is not None:
  193. post_asset_response = InternalApi().post(
  194. url_for("AssetAPI:post"),
  195. args=asset_form.to_json(),
  196. do_not_raise_for=[400, 422],
  197. )
  198. if post_asset_response.status_code in (200, 201):
  199. asset_dict = post_asset_response.json()
  200. asset = process_internal_api_response(
  201. asset_dict, int(asset_dict["id"]), make_obj=True
  202. )
  203. msg = "Creation was successful."
  204. else:
  205. current_app.logger.error(
  206. f"Internal asset API call unsuccessful [{post_asset_response.status_code}]: {post_asset_response.text}"
  207. )
  208. asset_form.process_api_validation_errors(post_asset_response.json())
  209. if "message" in post_asset_response.json():
  210. asset_form.process_api_validation_errors(
  211. post_asset_response.json()["message"]
  212. )
  213. if "json" in post_asset_response.json()["message"]:
  214. error_msg = str(
  215. post_asset_response.json()["message"]["json"]
  216. )
  217. if asset is None:
  218. msg = "Cannot create asset. " + error_msg
  219. return render_flexmeasures_template(
  220. "assets/asset_new.html",
  221. asset_form=asset_form,
  222. msg=msg,
  223. map_center=get_center_location_of_assets(user=current_user),
  224. mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""),
  225. )
  226. else:
  227. asset = get_asset_by_id_or_raise_notfound(id)
  228. check_access(asset, "update")
  229. asset_form = AssetForm()
  230. asset_form.with_options()
  231. if not asset_form.validate_on_submit():
  232. # Display the form data, but set some extra data which the page wants to show.
  233. asset_info = asset_form.to_json()
  234. asset_info = {
  235. k: v for k, v in asset_info.items() if k not in asset_form.errors
  236. }
  237. asset_info["id"] = id
  238. asset_info["account_id"] = asset.account_id
  239. asset = process_internal_api_response(
  240. asset_info, int(id), make_obj=True
  241. )
  242. session["msg"] = "Cannot edit asset."
  243. return redirect(url_for("AssetCrudUI:properties", id=asset.id))
  244. patch_asset_response = InternalApi().patch(
  245. url_for("AssetAPI:patch", id=id),
  246. args=asset_form.to_json(),
  247. do_not_raise_for=[400, 422],
  248. )
  249. asset_dict = patch_asset_response.json()
  250. if patch_asset_response.status_code in (200, 201):
  251. asset = process_internal_api_response(
  252. asset_dict, int(id), make_obj=True
  253. )
  254. msg = "Editing was successful."
  255. else:
  256. current_app.logger.error(
  257. f"Internal asset API call unsuccessful [{patch_asset_response.status_code}]: {patch_asset_response.text}"
  258. )
  259. msg = "Cannot edit asset."
  260. asset_form.process_api_validation_errors(
  261. patch_asset_response.json().get("message")
  262. )
  263. asset = db.session.get(GenericAsset, id)
  264. session["msg"] = msg
  265. return redirect(url_for("AssetCrudUI:properties", id=asset.id))
  266. @login_required
  267. def delete_with_data(self, id: str):
  268. """Delete via /assets/delete_with_data/<id>"""
  269. InternalApi().delete(url_for("AssetAPI:delete", id=id))
  270. return self.index(
  271. msg=f"Asset {id} and assorted meter readings / forecasts have been deleted."
  272. )
  273. @login_required
  274. @route("/<id>/auditlog")
  275. def auditlog(self, id: str):
  276. """/assets/<id>/auditlog"""
  277. asset = get_asset_by_id_or_raise_notfound(id)
  278. check_access(asset, "read")
  279. return render_flexmeasures_template(
  280. "assets/asset_audit_log.html",
  281. asset=asset,
  282. current_page="Audit Log",
  283. )
  284. @login_required
  285. @use_kwargs(StartEndTimeSchema, location="query")
  286. @route("/<id>/graphs")
  287. def graphs(self, id: str, start_time=None, end_time=None):
  288. """/assets/<id>/graphs"""
  289. asset = get_asset_by_id_or_raise_notfound(id)
  290. check_access(asset, "read")
  291. asset_form = AssetForm()
  292. asset_form.with_options()
  293. asset_form.process(obj=asset)
  294. return render_flexmeasures_template(
  295. "assets/asset_graph.html",
  296. asset=asset,
  297. current_page="Graphs",
  298. )
  299. @login_required
  300. @route("/<id>/properties")
  301. def properties(self, id: str):
  302. """/assets/<id>/properties"""
  303. # Extract the message from session
  304. if session.get("msg"):
  305. msg = session["msg"]
  306. session.pop("msg")
  307. else:
  308. msg = ""
  309. get_asset_response = InternalApi().get(url_for("AssetAPI:fetch_one", id=id))
  310. asset_dict = get_asset_response.json()
  311. asset = process_internal_api_response(asset_dict, int(id), make_obj=True)
  312. check_access(asset, "read")
  313. asset_form = AssetForm()
  314. asset_form.with_options()
  315. asset_form.process(data=process_internal_api_response(asset_dict))
  316. asset_summary = {
  317. "Name": asset.name,
  318. "Latitude": asset.latitude,
  319. "Longitude": asset.longitude,
  320. "Parent Asset": (
  321. f"{asset.parent_asset.name} ({asset.parent_asset.generic_asset_type.name})"
  322. if asset.parent_asset
  323. else "No Parent"
  324. ),
  325. }
  326. return render_flexmeasures_template(
  327. "assets/asset_properties.html",
  328. asset=asset,
  329. asset_summary=asset_summary,
  330. asset_form=asset_form,
  331. msg=msg,
  332. mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""),
  333. user_can_create_assets=user_can_create_assets(),
  334. user_can_delete_asset=user_can_delete(asset),
  335. user_can_update_asset=user_can_update(asset),
  336. current_page="Properties",
  337. )