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