responses.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. from __future__ import annotations
  2. from typing import Tuple, Union, Sequence
  3. import inflect
  4. from functools import wraps
  5. from flexmeasures.auth.error_handling import FORBIDDEN_MSG, FORBIDDEN_STATUS_CODE
  6. p = inflect.engine()
  7. # Type annotation for responses: (message, status_code) or (message, status_code, header)
  8. # todo: Use | instead of Union and tuple instead of Tuple when FM stops supporting Python 3.9 (because of https://github.com/python/cpython/issues/86399)
  9. ResponseTuple = Union[Tuple[dict, int], Tuple[dict, int, dict]]
  10. def is_response_tuple(value) -> bool:
  11. """Check if an object qualifies as a ResponseTuple"""
  12. if not isinstance(value, tuple):
  13. return False
  14. if not len(value) == 2:
  15. return False
  16. if not isinstance(value[0], dict):
  17. return False
  18. if not isinstance(value[1], int):
  19. return False
  20. return True
  21. class BaseMessage:
  22. """Set a base message to which extra info can be added by calling the wrapped function with additional string
  23. arguments. This is a decorator implemented as a class."""
  24. def __init__(self, base_message=""):
  25. self.base_message = base_message
  26. def __call__(self, func):
  27. @wraps(func)
  28. def my_logic(*args, **kwargs):
  29. message = self.base_message
  30. if args:
  31. for a in args:
  32. message += " %s" % a
  33. return func(message)
  34. return my_logic
  35. @BaseMessage("The requested API version is deprecated for this feature.")
  36. def deprecated_api_version(message: str) -> ResponseTuple:
  37. return dict(result="Rejected", status="INVALID_API_VERSION", message=message), 400
  38. @BaseMessage("Some of the data has already been received and successfully processed.")
  39. def already_received_and_successfully_processed(message: str) -> ResponseTuple:
  40. return (
  41. dict(
  42. results="PROCESSED",
  43. status="ALREADY_RECEIVED_AND_SUCCESSFULLY_PROCESSED",
  44. message=message,
  45. ),
  46. 200,
  47. )
  48. @BaseMessage(
  49. "Some of the data represents a replacement, which is reserved for servers in play mode. Enable play mode or update the prior in your request."
  50. )
  51. def invalid_replacement(message: str) -> ResponseTuple:
  52. return (
  53. dict(
  54. results="Rejected",
  55. status="INVALID_REPLACEMENT",
  56. message=message,
  57. ),
  58. 403,
  59. )
  60. @BaseMessage("Some of the required information is missing from the request.")
  61. def required_info_missing(fields: Sequence[str], message: str = "") -> ResponseTuple:
  62. return (
  63. dict(
  64. results="Rejected",
  65. status="REQUIRED_INFO_MISSING",
  66. message=f"Missing fields: {fields} - {message}",
  67. ),
  68. 400,
  69. )
  70. @BaseMessage(
  71. "Connections, sensors and markets should be identified using the EA1 addressing scheme recommended by USEF. "
  72. "For example:"
  73. " 'ea1.2018-06.io.flexmeasures:<owner_id>:<asset_id>'"
  74. " 'ea1.2018-06.io.flexmeasures:temperature:<latitude>:<longitude>'"
  75. " 'ea1.2018-06.io.flexmeasures:<market_name>'"
  76. " 'ea1.2018-06.io.flexmeasures:<owner_id>:<asset_id>:<event_id>:<event_type>'"
  77. )
  78. def invalid_domain(message: str) -> ResponseTuple:
  79. return dict(result="Rejected", status="INVALID_DOMAIN", message=message), 400
  80. @BaseMessage("The horizon field in your request could not be parsed.")
  81. def invalid_horizon(message: str) -> ResponseTuple:
  82. return dict(result="Rejected", status="INVALID_HORIZON", message=message), 400
  83. @BaseMessage("A time period in your request doesn't seem right.")
  84. def invalid_period(message: str) -> ResponseTuple:
  85. return dict(result="Rejected", status="INVALID_PERIOD", message=message), 400
  86. @BaseMessage(
  87. "Start time should be on the hour or a multiple of 15 minutes thereafter, "
  88. "duration should be some multiple N of 15 minutes, and "
  89. "the number of values should be some factor of N."
  90. )
  91. def invalid_ptu_duration(message: str) -> ResponseTuple:
  92. return (
  93. dict(result="Rejected", status="INVALID_PTU_DURATION", message=message),
  94. 400,
  95. )
  96. @BaseMessage("Only the following resolutions in the data are supported:")
  97. def unapplicable_resolution(message: str) -> ResponseTuple:
  98. return dict(result="Rejected", status="INVALID_RESOLUTION", message=message), 400
  99. @BaseMessage("The resolution string cannot be parsed as ISO8601 duration:")
  100. def invalid_resolution_str(message: str) -> ResponseTuple:
  101. return dict(result="Rejected", status="INVALID_RESOLUTION", message=message), 400
  102. @BaseMessage("The data source is not found:")
  103. def invalid_source(message: str) -> ResponseTuple:
  104. return dict(result="Rejected", status="INVALID_SOURCE", message=message), 400
  105. @BaseMessage("Requested assets do not have matching resolutions.")
  106. def conflicting_resolutions(message: str) -> ResponseTuple:
  107. return dict(result="Rejected", status="INVALID_RESOLUTION", message=message), 400
  108. def invalid_market() -> ResponseTuple:
  109. return (
  110. dict(
  111. result="Rejected",
  112. status="INVALID_MARKET",
  113. message="No market is registered for the requested asset.",
  114. ),
  115. 400,
  116. )
  117. def invalid_method(request_method) -> ResponseTuple:
  118. return (
  119. dict(
  120. result="Rejected",
  121. status="INVALID_METHOD",
  122. message="Request method %s not supported." % request_method,
  123. ),
  124. 405,
  125. )
  126. def invalid_role(requested_access_role: str) -> ResponseTuple:
  127. return (
  128. dict(
  129. result="Rejected",
  130. status="INVALID_ROLE",
  131. message="No known services for specified role %s." % requested_access_role,
  132. ),
  133. 400,
  134. )
  135. def invalid_sender(
  136. required_permissions: list[str] | None = None,
  137. ) -> ResponseTuple:
  138. """
  139. Signify that the sender is invalid to perform the request. Fits well with 403 errors.
  140. Optionally tell the user which permissions they should have.
  141. """
  142. message = FORBIDDEN_MSG
  143. if required_permissions:
  144. message += f" It requires {p.join(required_permissions)} permission(s)."
  145. return (
  146. dict(result="Rejected", status="INVALID_SENDER", message=message),
  147. FORBIDDEN_STATUS_CODE,
  148. )
  149. def invalid_timezone(message: str) -> ResponseTuple:
  150. return dict(result="Rejected", status="INVALID_TIMEZONE", message=message), 400
  151. @BaseMessage("Datetime cannot be used.")
  152. def invalid_datetime(message: str) -> ResponseTuple:
  153. return dict(result="Rejected", status="INVALID_DATETIME", message=message), 400
  154. def invalid_unit(
  155. quantity: str | None, units: Sequence[str] | tuple[str] | None
  156. ) -> ResponseTuple:
  157. quantity_str = (
  158. "for %s " % quantity.replace("_", " ") if quantity is not None else ""
  159. )
  160. unit_str = "in %s" % p.join(units, conj="or") if units is not None else "a unit"
  161. return (
  162. dict(
  163. result="Rejected",
  164. status="INVALID_UNIT",
  165. message="Data %sshould be given %s." % (quantity_str, unit_str),
  166. ),
  167. 400,
  168. )
  169. def invalid_message_type(message_type: str) -> ResponseTuple:
  170. return (
  171. dict(
  172. result="Rejected",
  173. status="INVALID_MESSAGE_TYPE",
  174. message="Request message should specify type '%s'." % message_type,
  175. ),
  176. 400,
  177. )
  178. @BaseMessage("Request message should include 'backup'.")
  179. def no_backup(message: str) -> ResponseTuple:
  180. return dict(result="Rejected", status="NO_BACKUP", message=message), 400
  181. @BaseMessage("Request message should include 'type'.")
  182. def no_message_type(message: str) -> ResponseTuple:
  183. return dict(result="Rejected", status="NO_MESSAGE_TYPE", message=message), 400
  184. @BaseMessage("One or more power values are too big.")
  185. def power_value_too_big(message: str) -> ResponseTuple:
  186. return dict(result="Rejected", status="POWER_VALUE_TOO_BIG", message=message), 400
  187. @BaseMessage("One or more power values are too small.")
  188. def power_value_too_small(message: str) -> ResponseTuple:
  189. return (
  190. dict(result="Rejected", status="POWER_VALUE_TOO_SMALL", message=message),
  191. 400,
  192. )
  193. @BaseMessage("Missing values.")
  194. def ptus_incomplete(message: str) -> ResponseTuple:
  195. return dict(result="Rejected", status="PTUS_INCOMPLETE", message=message), 400
  196. @BaseMessage("Missing prices for this time period.")
  197. def unknown_prices(message: str) -> ResponseTuple:
  198. return dict(result="Rejected", status="UNKNOWN_PRICES", message=message), 400
  199. @BaseMessage("No known schedule for this time period.")
  200. def unknown_schedule(message: str) -> ResponseTuple:
  201. return dict(result="Rejected", status="UNKNOWN_SCHEDULE", message=message), 400
  202. def fallback_schedule_redirect(message: str, location: str) -> ResponseTuple:
  203. return (
  204. dict(result="Rejected", status="UNKNOWN_SCHEDULE", message=message),
  205. 303,
  206. dict(location=location),
  207. )
  208. def invalid_flex_config(message: str) -> ResponseTuple:
  209. return (
  210. dict(
  211. result="Rejected", status="UNPROCESSABLE_ENTITY", message=dict(json=message)
  212. ),
  213. 422,
  214. )
  215. @BaseMessage("The requested backup is not known.")
  216. def unrecognized_backup(message: str) -> ResponseTuple:
  217. return dict(result="Rejected", status="UNRECOGNIZED_BACKUP", message=message), 400
  218. @BaseMessage("One or more connections in your request were not found in your account.")
  219. def unrecognized_connection_group(message: str) -> ResponseTuple:
  220. return (
  221. dict(
  222. result="Rejected", status="UNRECOGNIZED_CONNECTION_GROUP", message=message
  223. ),
  224. 400,
  225. )
  226. def incomplete_event(
  227. requested_event_id, requested_event_type, message
  228. ) -> ResponseTuple:
  229. return (
  230. dict(
  231. result="Rejected",
  232. status="INCOMPLETE_UDI_EVENT",
  233. message="The requested UDI event (id = %s, type = %s) is incomplete."
  234. % (requested_event_id, requested_event_type),
  235. ),
  236. 400,
  237. )
  238. def unrecognized_event(requested_event_id, requested_event_type) -> ResponseTuple:
  239. return (
  240. dict(
  241. result="Rejected",
  242. status="UNRECOGNIZED_UDI_EVENT",
  243. message="The requested UDI event (id = %s, type = %s) is not known."
  244. % (requested_event_id, requested_event_type),
  245. ),
  246. 400,
  247. )
  248. def unrecognized_event_type(requested_event_type) -> ResponseTuple:
  249. return (
  250. dict(
  251. result="Rejected",
  252. status="UNRECOGNIZED_UDI_EVENT",
  253. message="The requested UDI event type %s is not known."
  254. % requested_event_type,
  255. ),
  256. 400,
  257. )
  258. def outdated_event_id(requested_event_id, existing_event_id) -> ResponseTuple:
  259. return (
  260. dict(
  261. result="Rejected",
  262. status="OUTDATED_UDI_EVENT",
  263. message="The requested UDI event (id = %s) is equal or before the latest existing one (id = %s)."
  264. % (requested_event_id, existing_event_id),
  265. ),
  266. 400,
  267. )
  268. def unrecognized_market(requested_market) -> ResponseTuple:
  269. return (
  270. dict(
  271. result="Rejected",
  272. status="UNRECOGNIZED_MARKET",
  273. message="The requested market named %s is not known." % requested_market,
  274. ),
  275. 400,
  276. )
  277. def unrecognized_sensor(
  278. lat: float | None = None, lng: float | None = None
  279. ) -> ResponseTuple:
  280. base_message = "No sensor is known at this location."
  281. if lat is not None and lng is not None:
  282. message = (
  283. base_message
  284. + " The nearest sensor is at latitude %s and longitude %s" % (lat, lng)
  285. )
  286. else:
  287. message = base_message + " In fact, we can't find any sensors."
  288. return dict(result="Rejected", status="UNRECOGNIZED_SENSOR", message=message), 400
  289. @BaseMessage("Cannot identify asset.")
  290. def unrecognized_asset(message: str) -> ResponseTuple:
  291. return dict(status="UNRECOGNIZED_ASSET", message=message), 400
  292. @BaseMessage("Request has been processed.")
  293. def request_processed(message: str) -> ResponseTuple:
  294. return dict(status="PROCESSED", message=message), 200
  295. def pluralize(usef_role_name: str) -> str:
  296. """Adding a trailing 's' works well for USEF roles."""
  297. return "%ss" % usef_role_name