validators.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. from __future__ import annotations
  2. from datetime import datetime, timedelta
  3. from functools import wraps
  4. import re
  5. import isodate
  6. from isodate.isoerror import ISO8601Error
  7. from flask import request, current_app
  8. from flask_json import as_json
  9. import marshmallow
  10. from webargs.flaskparser import parser
  11. from flexmeasures.data.schemas.times import DurationField
  12. from flexmeasures.api.common.responses import ( # noqa: F401
  13. required_info_missing,
  14. invalid_horizon,
  15. invalid_method,
  16. invalid_message_type,
  17. invalid_period,
  18. unapplicable_resolution,
  19. invalid_resolution_str,
  20. conflicting_resolutions,
  21. invalid_source,
  22. invalid_timezone,
  23. invalid_unit,
  24. no_message_type,
  25. ptus_incomplete,
  26. unrecognized_connection_group,
  27. unrecognized_asset,
  28. )
  29. """
  30. This module has validators used by API endpoints <= 2.0 to describe
  31. acceptable parameters.
  32. We aim to make this module obsolete by using Marshmallow.
  33. Marshmallow is a better format to describe valid data.
  34. There is some actual logic in here, which we still need. It can usually be ported to Marshmallow validators.
  35. """
  36. def parse_horizon(horizon_str: str) -> tuple[timedelta | None, bool]:
  37. """
  38. Validates whether a horizon string represents a valid ISO 8601 (repeating) time interval.
  39. Examples:
  40. horizon = "PT6H"
  41. horizon = "R/PT6H"
  42. horizon = "-PT10M"
  43. Returns horizon as timedelta and a boolean indicating whether the repetitive indicator "R/" was used.
  44. If horizon_str could not be parsed with various methods, then horizon will be None
  45. """
  46. # negativity
  47. neg = False
  48. if horizon_str[0] == "-":
  49. neg = True
  50. horizon_str = horizon_str[1:]
  51. # repetition-encoding
  52. is_repetition: bool = False
  53. if re.search(r"^R\d*/", horizon_str):
  54. _, horizon_str, *_ = re.split("/", horizon_str)
  55. is_repetition = True
  56. # parse
  57. try:
  58. horizon: timedelta = isodate.parse_duration(horizon_str)
  59. except (ISO8601Error, AttributeError):
  60. return None, is_repetition
  61. if neg:
  62. horizon = -horizon
  63. return horizon, is_repetition
  64. def parse_duration(
  65. duration_str: str, start: datetime | None = None
  66. ) -> timedelta | isodate.Duration | None:
  67. """
  68. Parses the 'duration' string into a Duration object.
  69. If needed, try deriving the timedelta from the actual time span (e.g. in case duration is 1 year).
  70. If the string is not a valid ISO 8601 time interval, return None.
  71. TODO: Deprecate for DurationField.
  72. """
  73. try:
  74. duration = isodate.parse_duration(duration_str)
  75. if not isinstance(duration, timedelta) and start:
  76. return (start + duration) - start
  77. # if not a timedelta, then it's a valid duration (e.g. "P1Y" could be leap year)
  78. return duration
  79. except (ISO8601Error, AttributeError):
  80. return None
  81. def optional_duration_accepted(default_duration: timedelta):
  82. """Decorator which specifies that a GET or POST request accepts an optional duration.
  83. It parses relevant form data and sets the "duration" keyword param.
  84. Example:
  85. @app.route('/getDeviceMessage')
  86. @optional_duration_accepted(timedelta(hours=6))
  87. def get_device_message(duration):
  88. return 'Here is your message'
  89. The message may specify a duration to overwrite the default duration of 6 hours.
  90. """
  91. def wrapper(fn):
  92. @wraps(fn)
  93. @as_json
  94. def decorated_service(*args, **kwargs):
  95. duration_arg = parser.parse(
  96. {"duration": DurationField()},
  97. request,
  98. location="args_and_json",
  99. unknown=marshmallow.EXCLUDE,
  100. )
  101. if "duration" in duration_arg:
  102. duration = duration_arg["duration"]
  103. duration = DurationField.ground_from(
  104. duration,
  105. kwargs.get("start", kwargs.get("datetime", None)),
  106. )
  107. if not duration: # TODO: deprecate
  108. extra_info = "Cannot parse 'duration' value."
  109. current_app.logger.warning(extra_info)
  110. return invalid_period(extra_info)
  111. kwargs["duration"] = duration
  112. else:
  113. kwargs["duration"] = default_duration
  114. return fn(*args, **kwargs)
  115. return decorated_service
  116. return wrapper