diff --git a/flexmeasures/api/common/schemas/tests/__init__.py b/flexmeasures/api/common/schemas/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/flexmeasures/api/common/schemas/tests/test_times.py b/flexmeasures/api/common/schemas/tests/test_times.py new file mode 100644 index 000000000..475cd7a08 --- /dev/null +++ b/flexmeasures/api/common/schemas/tests/test_times.py @@ -0,0 +1,74 @@ +from datetime import datetime, timedelta + +import pytest +import pytz +import isodate + +from flexmeasures.api.common.schemas.times import DurationField, DurationValidationError + + +@pytest.mark.parametrize( + "duration_input,exp_deserialization", + [ + ("PT1H", timedelta(hours=1)), + ("PT6M", timedelta(minutes=6)), + ("PT6H", timedelta(hours=6)), + ("P2DT1H", timedelta(hours=49)), + ], +) +def test_duration_field_straightforward(duration_input, exp_deserialization): + """Testing straightforward cases""" + df = DurationField() + deser = df.deserialize(duration_input, None, None) + assert deser == exp_deserialization + assert df.serialize("duration", {"duration": deser}) == duration_input + + +@pytest.mark.parametrize( + "duration_input,exp_deserialization,grounded_timedelta", + [ + ("P1M", isodate.Duration(months=1), timedelta(days=29)), + ("PT24H", isodate.Duration(hours=24), timedelta(hours=24)), + ("P2D", isodate.Duration(hours=48), timedelta(hours=48)), + # following are calendar periods including a transition to daylight saving time (DST) + ("P2M", isodate.Duration(months=2), timedelta(days=60) - timedelta(hours=1)), + # ("P8W", isodate.Duration(days=7*8), timedelta(weeks=8) - timedelta(hours=1)), + # ("P100D", isodate.Duration(days=100), timedelta(days=100) - timedelta(hours=1)), + # following is a calendar period with transitions to DST and back again + ("P1Y", isodate.Duration(years=1), timedelta(days=366)), + ], +) +def test_duration_field_nominal_grounded( + duration_input, exp_deserialization, grounded_timedelta +): + """Nominal durations are tricky: + https://en.wikipedia.org/wiki/Talk:ISO_8601/Archive_2#Definition_of_Duration_is_incorrect + We want to test if we can ground them as expected. + We use a particular datetime to ground, in a leap year February. + For the Europe/Amsterdam timezone, daylight saving time started on March 29th 2020. + # todo: the commented out tests would work if isodate.parse_duration would have the option to stop coercing ISO 8601 days into datetime.timedelta days + """ + df = DurationField() + deser = df.deserialize(duration_input, None, None) + assert deser == exp_deserialization + dummy_time = pytz.timezone("Europe/Amsterdam").localize( + datetime(2020, 2, 22, 18, 7) + ) + grounded = DurationField.ground_from(deser, dummy_time) + assert grounded == grounded_timedelta + + +@pytest.mark.parametrize( + "duration_input,error_msg", + [ + ("", "Unable to parse duration string"), + ("1H", "Unable to parse duration string"), + ("PP1M", "time designator 'T' missing"), + ("PT2D", "Unrecognised ISO 8601 date format"), + ], +) +def test_duration_field_invalid(duration_input, error_msg): + df = DurationField() + with pytest.raises(DurationValidationError) as ve: + df.deserialize(duration_input, None, None) + assert error_msg in str(ve) diff --git a/flexmeasures/api/common/schemas/times.py b/flexmeasures/api/common/schemas/times.py new file mode 100644 index 000000000..8f2108ee8 --- /dev/null +++ b/flexmeasures/api/common/schemas/times.py @@ -0,0 +1,64 @@ +from typing import Union, Optional +from datetime import datetime, timedelta + +from marshmallow import fields +import isodate +from isodate.isoerror import ISO8601Error +import pandas as pd + +from flexmeasures.api.common.utils.args_parsing import FMValidationError + + +class DurationValidationError(FMValidationError): + status = "INVALID_PERIOD" # USEF error status + + +class DurationField(fields.Str): + """Field that deserializes to a ISO8601 Duration + and serializes back to a string.""" + + def _deserialize( + self, value, attr, obj, **kwargs + ) -> Union[timedelta, isodate.Duration]: + """ + Use the isodate library to turn an ISO8601 string into a timedelta. + For some non-obvious cases, it will become an isodate.Duration, see + ground_from for more. + This method throws a ValidationError if the string is not ISO norm. + """ + try: + return isodate.parse_duration(value) + except ISO8601Error as iso_err: + raise DurationValidationError( + f"Cannot parse {value} as ISO8601 duration: {iso_err}" + ) + + def _serialize(self, value, attr, data, **kwargs): + """ + An implementation of _serialize. + It is not guaranteed to return the same string as was input, + if ground_from has been used! + """ + return isodate.strftime(value, "P%P") + + @staticmethod + def ground_from( + duration: Union[timedelta, isodate.Duration], start: Optional[datetime] + ) -> timedelta: + """ + For some valid duration strings (such as "P1M", a month), + converting to a datetime.timedelta is not possible (no obvious + number of days). In this case, `_deserialize` returned an + `isodate.Duration`. We can derive the timedelta by grounding to an + actual time span, for which we require a timezone-aware start datetime. + """ + if isinstance(duration, isodate.Duration) and start: + years = duration.years + months = duration.months + days = duration.days + seconds = duration.tdelta.seconds + offset = pd.DateOffset( + years=years, months=months, days=days, seconds=seconds + ) + return (pd.Timestamp(start) + offset).to_pydatetime() - start + return duration diff --git a/flexmeasures/api/common/utils/args_parsing.py b/flexmeasures/api/common/utils/args_parsing.py index 1d868fb88..46d9a0862 100644 --- a/flexmeasures/api/common/utils/args_parsing.py +++ b/flexmeasures/api/common/utils/args_parsing.py @@ -4,37 +4,48 @@ from webargs.flaskparser import parser """ -Utils for argument parsing (we use webargs) +Utils for argument parsing (we use webargs), +including error handling. """ -class FMValidationError(Exception): - """ Custom validation error class """ +@parser.error_handler +def handle_error(error, req, schema, *, error_status_code, error_headers): + """Replacing webargs's error parser, so we can throw custom Exceptions.""" + if error.__class__ == ValidationError: + # re-package all marshmallow's validation errors as our own kind (see below) + raise FMValidationError(message=error.messages) + raise error + + +class FMValidationError(ValidationError): + """ + Custom validation error class. + It differs from the classic validation error by having two + attributes, according to the USEF 2015 reference implementation. + Subclasses of this error might adjust the `status` attribute accordingly. + """ - def __init__(self, messages): - self.result = "Rejected" - self.status = "UNPROCESSABLE_ENTITY" - self.messages = messages + result = "Rejected" + status = "UNPROCESSABLE_ENTITY" -def validation_error_handler(error): - """Handles errors during parsing. Aborts the current HTTP request and - responds with a 422 error. +def validation_error_handler(error: FMValidationError): + """Handles errors during parsing. + Aborts the current HTTP request and responds with a 422 error. + FMValidationError attributes "result" and "status" are packaged in the response. """ status_code = 422 - response = jsonify(error.messages) + response_data = dict(message=error.messages) + if hasattr(error, "result"): + response_data["result"] = error.result + if hasattr(error, "status"): + response_data["status"] = error.status + response = jsonify(response_data) response.status_code = status_code return response -@parser.error_handler -def handle_error(error, req, schema, *, error_status_code, error_headers): - """Replacing webargs's error parser, so we can throw custom Exceptions.""" - if error.__class__ == ValidationError: - raise FMValidationError(messages=error.messages) - raise error - - @parser.location_loader("args_and_json") def load_data(request, schema): """ diff --git a/flexmeasures/api/common/utils/validators.py b/flexmeasures/api/common/utils/validators.py index d1cd4da10..21f65e086 100644 --- a/flexmeasures/api/common/utils/validators.py +++ b/flexmeasures/api/common/utils/validators.py @@ -14,9 +14,9 @@ from flask_security import current_user import marshmallow -from webargs import fields from webargs.flaskparser import parser +from flexmeasures.api.common.schemas.times import DurationField from flexmeasures.api.common.responses import ( # noqa: F401 required_info_missing, invalid_horizon, @@ -134,30 +134,19 @@ def parse_duration( Parses the 'duration' string into a Duration object. If needed, try deriving the timedelta from the actual time span (e.g. in case duration is 1 year). If the string is not a valid ISO 8601 time interval, return None. + + TODO: Deprecate for DurationField. """ try: duration = isodate.parse_duration(duration_str) - if not isinstance(duration, timedelta): - if start: - return (start + duration) - start - return duration # valid duration, but not a timedelta (e.g. "P1Y" could be leap year) - else: - return isodate.parse_duration(duration_str) + if not isinstance(duration, timedelta) and start: + return (start + duration) - start + # if not a timedelta, then it's a valid duration (e.g. "P1Y" could be leap year) + return duration except (ISO8601Error, AttributeError): return None -def validate_duration_field(duration_str): - """Validate a marshmallow ISO8601 duration field, - throw marshmallow validation error if it cannot be parsed.""" - try: - isodate.parse_duration(duration_str) - except ISO8601Error as iso_err: - raise marshmallow.ValidationError( - f"Cannot parse {duration_str} as ISO8601 duration: {iso_err}" - ) - - def parse_isodate_str(start: str) -> Union[datetime, None]: """ Validates whether the string 'start' is a valid ISO 8601 datetime. @@ -203,18 +192,18 @@ def wrapper(fn): @as_json def decorated_service(*args, **kwargs): duration_arg = parser.parse( - {"duration": fields.Str(validate=validate_duration_field)}, + {"duration": DurationField()}, request, location="args_and_json", unknown=marshmallow.EXCLUDE, ) - if "duration" in duration_arg: - duration = parse_duration( - duration_arg["duration"], + duration = duration_arg["duration"] + duration = DurationField.ground_from( + duration, kwargs.get("start", kwargs.get("datetime", None)), ) - if not duration: + if not duration: # TODO: deprecate extra_info = "Cannot parse 'duration' value." current_app.logger.warning(extra_info) return invalid_period(extra_info) diff --git a/flexmeasures/api/v1_2/tests/test_api_v1_2.py b/flexmeasures/api/v1_2/tests/test_api_v1_2.py index 1f4da1e32..c439d3c83 100644 --- a/flexmeasures/api/v1_2/tests/test_api_v1_2.py +++ b/flexmeasures/api/v1_2/tests/test_api_v1_2.py @@ -69,7 +69,7 @@ def test_get_device_message_mistyped_duration(client): assert get_device_message_response.status_code == 422 assert ( "Cannot parse PTT6H as ISO8601 duration" - in get_device_message_response.json["args_and_json"]["duration"][0] + in get_device_message_response.json["message"]["args_and_json"]["duration"][0] ) diff --git a/flexmeasures/api/v2_0/tests/test_api_v2_0_assets.py b/flexmeasures/api/v2_0/tests/test_api_v2_0_assets.py index 222ef094b..9b26a584f 100644 --- a/flexmeasures/api/v2_0/tests/test_api_v2_0_assets.py +++ b/flexmeasures/api/v2_0/tests/test_api_v2_0_assets.py @@ -166,7 +166,7 @@ def test_post_an_asset_with_nonexisting_field(client): headers={"content-type": "application/json", "Authorization": auth_token}, ) assert asset_creation.status_code == 422 - assert asset_creation.json["json"]["nnname"][0] == "Unknown field." + assert asset_creation.json["message"]["json"]["nnname"][0] == "Unknown field." def test_posting_multiple_assets(client): @@ -183,7 +183,7 @@ def test_posting_multiple_assets(client): ) print(f"Response: {asset_creation.json}") assert asset_creation.status_code == 422 - assert asset_creation.json["json"]["_schema"][0] == "Invalid input type." + assert asset_creation.json["message"]["json"]["_schema"][0] == "Invalid input type." def test_post_an_asset(client): @@ -234,16 +234,16 @@ def test_post_an_asset_with_invalid_data(client, db): assert ( "Must be greater than or equal to 0" - in post_asset_response.json["json"]["capacity_in_mw"][0] + in post_asset_response.json["message"]["json"]["capacity_in_mw"][0] ) assert ( "greater than or equal to -180 and less than or equal to 180" - in post_asset_response.json["json"]["longitude"][0] + in post_asset_response.json["message"]["json"]["longitude"][0] ) - assert "required field" in post_asset_response.json["json"]["unit"][0] + assert "required field" in post_asset_response.json["message"]["json"]["unit"][0] assert ( "must be equal or higher than the minimum soc" - in post_asset_response.json["json"]["max_soc_in_mwh"] + in post_asset_response.json["message"]["json"]["max_soc_in_mwh"] ) assert Asset.query.filter_by(owner_id=prosumer.id).count() == num_assets_before diff --git a/flexmeasures/app.py b/flexmeasures/app.py index 8e785b789..e2663c4cc 100644 --- a/flexmeasures/app.py +++ b/flexmeasures/app.py @@ -6,6 +6,7 @@ from flask_mail import Mail from flask_sslify import SSLify from flask_json import FlaskJSON +from flask_cors import CORS from redis import Redis from rq import Queue @@ -43,6 +44,7 @@ def create(env=None) -> Flask: app.mail = Mail(app) FlaskJSON(app) + cors = CORS(app) # configure Redis (for redis queue) if app.testing: @@ -80,18 +82,18 @@ def create(env=None) -> Flask: register_db_at(app) - # Register the UI - - from flexmeasures.ui import register_at as register_ui_at - - register_ui_at(app) - # Register the API from flexmeasures.api import register_at as register_api_at register_api_at(app) + # Register the UI + + from flexmeasures.ui import register_at as register_ui_at + + register_ui_at(app) + # Profile endpoints (if needed, e.g. during development) @app.before_request def before_request(): diff --git a/flexmeasures/ui/__init__.py b/flexmeasures/ui/__init__.py index e37c2a5c1..098dea01c 100644 --- a/flexmeasures/ui/__init__.py +++ b/flexmeasures/ui/__init__.py @@ -15,6 +15,7 @@ localized_datetime_str, naturalized_datetime_str, ) +from flexmeasures.api.v2_0 import flexmeasures_api as flexmeasures_api_v2_0 # The ui blueprint. It is registered with the Flask app (see app.py) flexmeasures_ui = Blueprint( @@ -57,6 +58,11 @@ def favicon(): add_jinja_filters(app) add_jinja_variables(app) + # re-register api blueprint so it'll register the chart views (the ones in views.charts) + app.register_blueprint( + flexmeasures_api_v2_0, url_prefix="/api/v2_0", first_registration=False + ) + def register_rq_dashboard(app): app.config.update( diff --git a/flexmeasures/ui/utils/plotting_utils.py b/flexmeasures/ui/utils/plotting_utils.py index a7008bffe..b21adeece 100644 --- a/flexmeasures/ui/utils/plotting_utils.py +++ b/flexmeasures/ui/utils/plotting_utils.py @@ -251,6 +251,7 @@ def create_graph( # noqa: C901 show_y_floats: bool = False, non_negative_only: bool = False, tools: List[str] = None, + sizing_mode="scale_width", ) -> Figure: """ Create a Bokeh graph. As of now, assumes x data is datetimes and y data is numeric. The former is not set in stone. @@ -310,7 +311,7 @@ def create_graph( # noqa: C901 tools=tools, h_symmetry=False, v_symmetry=False, - sizing_mode="scale_width", + sizing_mode=sizing_mode, outline_line_color="#666666", ) diff --git a/flexmeasures/ui/views/__init__.py b/flexmeasures/ui/views/__init__.py index c4441ae5b..44b39ca86 100644 --- a/flexmeasures/ui/views/__init__.py +++ b/flexmeasures/ui/views/__init__.py @@ -11,6 +11,8 @@ from flexmeasures.ui.views.account import account_view # noqa: F401 # noqa: F401 +from flexmeasures.ui.views.charts import get_power_chart # noqa: F401 + @flexmeasures_ui.route("/docs") def docs_view(): diff --git a/flexmeasures/ui/views/analytics.py b/flexmeasures/ui/views/analytics.py index 1b95949e3..51ab9482b 100644 --- a/flexmeasures/ui/views/analytics.py +++ b/flexmeasures/ui/views/analytics.py @@ -528,6 +528,7 @@ def make_power_figure( show_consumption_as_positive: bool, shared_x_range: Range1d, tools: List[str] = None, + sizing_mode="scale_width", ) -> Figure: """Make a bokeh figure for power consumption or generation""" if show_consumption_as_positive: @@ -535,6 +536,16 @@ def make_power_figure( else: title = "Electricity production from %s" % resource_display_name + resolution_str = "?" + if hasattr(data.index, "freq") and data.index.freq is not None: + resolution_str = time_utils.freq_label_to_human_readable_label( + data.index.freqstr + ) + elif "resolution" in session: + resolution_str = time_utils.freq_label_to_human_readable_label( + session["resolution"] + ) + return create_graph( data, unit="MW", @@ -546,11 +557,11 @@ def make_power_figure( schedules=schedule_data, title=title, x_range=shared_x_range, - x_label="Time (resolution of %s)" - % time_utils.freq_label_to_human_readable_label(session["resolution"]), + x_label="Time (resolution of %s)" % resolution_str, y_label="Power (in MW)", show_y_floats=True, tools=tools, + sizing_mode=sizing_mode, ) @@ -560,6 +571,7 @@ def make_prices_figure( shared_x_range: Range1d, selected_market: Market, tools: List[str] = None, + sizing_mode="scale_width", ) -> Figure: """Make a bokeh figure for price data""" return create_graph( @@ -574,6 +586,7 @@ def make_prices_figure( y_label="Price (in %s)" % selected_market.unit, show_y_floats=True, tools=tools, + sizing_mode=sizing_mode, ) @@ -584,6 +597,7 @@ def make_weather_figure( shared_x_range: Range1d, weather_sensor: WeatherSensor, tools: List[str] = None, + sizing_mode="scale_width", ) -> Figure: """Make a bokeh figure for weather data""" # Todo: plot average temperature/total_radiation/wind_speed for asset groups, and update title accordingly @@ -614,6 +628,7 @@ def make_weather_figure( legend_location="top_right", show_y_floats=True, tools=tools, + sizing_mode=sizing_mode, ) @@ -625,6 +640,7 @@ def make_revenues_costs_figure( shared_x_range: Range1d, selected_market: Market, tools: List[str] = None, + sizing_mode="scale_width", ) -> Figure: """Make a bokeh figure for revenues / costs data""" if show_consumption_as_positive: @@ -646,6 +662,7 @@ def make_revenues_costs_figure( y_label="%s (in %s)" % (rev_cost_str, selected_market.unit[:3]), show_y_floats=True, tools=tools, + sizing_mode=sizing_mode, ) diff --git a/flexmeasures/ui/views/charts.py b/flexmeasures/ui/views/charts.py new file mode 100644 index 000000000..23585be5c --- /dev/null +++ b/flexmeasures/ui/views/charts.py @@ -0,0 +1,132 @@ +from flask_security import roles_accepted +from flask_json import as_json +from bokeh.embed import json_item +from marshmallow import Schema, fields +from webargs.flaskparser import use_args + +from flexmeasures.api.v2_0 import flexmeasures_api as flexmeasures_api_v2_0 +from flexmeasures.api.v2_0.routes import v2_0_service_listing +from flexmeasures.api.common.schemas.times import DurationField +from flexmeasures.data.queries.analytics import get_power_data +from flexmeasures.ui.views.analytics import make_power_figure + + +""" +An endpoint to get a power chart. + +This will grow to become code for more charts eventually. +The plan is to separate charts specs from the actual data later, +and to switch to Altair. + +For now, we'll keep this endpoint here, with route and implementation in the same file. +When we move forward, we'll review the architecture. +""" + + +v2_0_service_listing["services"].append( + { + "name": "GET /charts/power", + "access": ["admin", "Prosumer"], + "description": "Get a Bokeh chart for power data to embed in web pages.", + }, +) + + +class ChartRequestSchema(Schema): + """ + This schema describes the request for a chart. + """ + + resource = fields.Str(required=True) + start_time = fields.DateTime(required=True) + end_time = fields.DateTime(required=True) + resolution = DurationField(required=True) + show_consumption_as_positive = fields.Bool(missing=True) + show_individual_traces_for = fields.Str( + missing="none", validate=lambda x: x in ("none", "schedules", "power") + ) + forecast_horizon = DurationField(missing="PT6H") + + +@flexmeasures_api_v2_0.route("/charts/power", methods=["GET"]) +@roles_accepted("admin", "Prosumer") +@use_args(ChartRequestSchema(), location="querystring") +@as_json +def get_power_chart(chart_request): + """API endpoint to get a chart for power data which can be embedded in web pages. + + .. :quickref: Chart; Get a power chart + + This endpoint returns a Bokeh chart with power data which can be embedded in a website. + It includes forecasts and even schedules, if available. + + **Example request** + + An example of a chart request: + + .. sourcecode:: json + + { + "resource": ""my-battery, + "start_time": "2020-02-20:10:00:00UTC", + "end_time": "2020-02-20:11:00:00UTC", + "resolution": "PT15M", + "consumption_as_positive": true + "resolution": "PT6H", + } + + On your webpage, you need to include the Bokeh libraries, e.g.: + + + + (The version needs to match the version used by the FlexMeasures server, see requirements/app.txt) + + Then you can call this endpoint and include the result like this: + + .. sourcecode:: javascript + + + + where `urlData` is a `URLSearchData` object and contains the chart request parameters (see above). + + :reqheader Authorization: The authentication token + :reqheader Content-Type: application/json + :resheader Content-Type: application/json + :status 200: PROCESSED + :status 400: INVALID_REQUEST + :status 401: UNAUTHORIZED + :status 403: INVALID_SENDER + :status 422: UNPROCESSABLE_ENTITY + """ + data = get_power_data( + resource=chart_request["resource"], + show_consumption_as_positive=chart_request["show_consumption_as_positive"], + showing_individual_traces_for=chart_request["show_individual_traces_for"], + metrics={}, # will be stored here, we don't need them for now + query_window=(chart_request["start_time"], chart_request["end_time"]), + resolution=chart_request["resolution"], + forecast_horizon=chart_request["forecast_horizon"], + ) + figure = make_power_figure( + resource_display_name=chart_request["resource"], + data=data[0], + forecast_data=data[1], + schedule_data=data[2], + show_consumption_as_positive=chart_request["show_consumption_as_positive"], + shared_x_range=None, + sizing_mode="scale_both", + ) + return json_item(figure) diff --git a/flexmeasures/utils/config_defaults.py b/flexmeasures/utils/config_defaults.py index 349677d60..90637bef9 100644 --- a/flexmeasures/utils/config_defaults.py +++ b/flexmeasures/utils/config_defaults.py @@ -1,6 +1,6 @@ from datetime import timedelta import logging -from typing import List, Optional +from typing import List, Optional, Union """ This lays out our configuration requirements and allows to set trivial defaults, per environment adjustable. @@ -59,6 +59,12 @@ class Config(object): SECURITY_TRACKABLE = False # this is more in line with modern privacy law SECURITY_PASSWORD_SALT: Optional[str] = None + # Allowed cross-origins. Set to "*" to allow all. For development (e.g. javascript on localhost) you might use "null" here + CORS_ORIGINS: Union[List[str], str] = [] + # this can be a dict with all possible options as value per regex, see https://flask-cors.readthedocs.io/en/latest/configuration.html + CORS_RESOURCES: Union[dict, list, str] = [r"/api/*"] + CORS_SUPPORTS_CREDENTIALS: bool = True + DARK_SKY_API_KEY: Optional[str] = None MAPBOX_ACCESS_TOKEN: Optional[str] = None diff --git a/requirements/app.in b/requirements/app.in index 71d979b7c..a4caae833 100644 --- a/requirements/app.in +++ b/requirements/app.in @@ -43,7 +43,8 @@ Flask-Mail Flask-Security-Too Flask-Classful Flask-Marshmallow +Flask-Cors marshmallow-sqlalchemy>=0.23.1 webargs # flask should be after all the flask plugins, because setup might find they ARE flask -flask>=1.0 \ No newline at end of file +flask>=1.0 diff --git a/requirements/app.txt b/requirements/app.txt index 6557ad619..045379400 100644 --- a/requirements/app.txt +++ b/requirements/app.txt @@ -27,6 +27,7 @@ entrypoints==0.3 # via altair filelock==3.0.12 # via tldextract flask-babelex==0.9.4 # via flask-security-too flask-classful==0.14.2 # via -r requirements/app.in +flask-cors==3.0.10 # via -r requirements/app.in flask-json==0.3.4 # via -r requirements/app.in flask-login==0.5.0 # via -r requirements/app.in, flask-security-too flask-mail==0.9.1 # via -r requirements/app.in, flask-security-too @@ -37,7 +38,7 @@ flask-security-too==3.4.4 # via -r requirements/app.in flask-sqlalchemy==2.4.4 # via -r requirements/app.in, flask-migrate flask-sslify==0.1.5 # via -r requirements/app.in flask-wtf==0.14.3 # via -r requirements/app.in, flask-security-too -flask==1.1.2 # via -r requirements/app.in, flask-babelex, flask-classful, flask-json, flask-login, flask-mail, flask-marshmallow, flask-migrate, flask-principal, flask-security-too, flask-sqlalchemy, flask-sslify, flask-wtf, rq-dashboard +flask==1.1.2 # via -r requirements/app.in, flask-babelex, flask-classful, flask-cors, flask-json, flask-login, flask-mail, flask-marshmallow, flask-migrate, flask-principal, flask-security-too, flask-sqlalchemy, flask-sslify, flask-wtf, rq-dashboard forecastiopy==0.22 # via -r requirements/app.in humanize==2.6.0 # via -r requirements/app.in idna==2.10 # via email-validator, requests, tldextract @@ -91,7 +92,7 @@ scikit-learn==0.23.2 # via sklearn scipy==1.5.2 # via properscoring, pvlib, scikit-learn, statsmodels, timely-beliefs, timetomodel selenium==3.141.0 # via timely-beliefs siphon==0.8.0 # via -r requirements/app.in -six==1.15.0 # via altair, bcrypt, bokeh, cycler, flask-marshmallow, isodate, jsonschema, packaging, patsy, protobuf, pyomo, python-dateutil, pyutilib, requests-file +six==1.15.0 # via altair, bcrypt, bokeh, cycler, flask-cors, flask-marshmallow, isodate, jsonschema, packaging, patsy, protobuf, pyomo, python-dateutil, pyutilib, requests-file sklearn==0.0 # via timetomodel soupsieve==2.2 # via beautifulsoup4 speaklater==1.3 # via flask-babelex