Skip to content

Commit b39ab79

Browse files
Add GET /api/v3_0/sources endpoint for accessible data sources and types
Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/319e247a-b8ab-4340-95e2-d1fd41e39d4e Co-authored-by: BelhsanHmida <149331360+BelhsanHmida@users.noreply.github.com>
1 parent 7c87c49 commit b39ab79

4 files changed

Lines changed: 378 additions & 0 deletions

File tree

documentation/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ New features
1111
* Added API support for copying assets and their subtrees [see `PR #2017 <https://www.github.com/FlexMeasures/flexmeasures/pull/2017>`_]
1212
* Improve UX after deleting a child asset through the UI [see `PR #2119 <https://www.github.com/FlexMeasures/flexmeasures/pull/2119>`_]
1313
* Improve source filtering in the sensor data GET endpoint by exposing the documented query parameters in Swagger and allowing filtering by the account linked to data sources [see `PR #2083 <https://www.github.com/FlexMeasures/flexmeasures/pull/2083>`_]
14+
* New ``GET /api/v3_0/sources`` endpoint to list accessible data sources and defined types, with optional ``only_latest`` toggle to return only the most recent version per source [see `PR #2125 <https://www.github.com/FlexMeasures/flexmeasures/pull/2125>`_]
1415

1516
Infrastructure / Support
1617
----------------------

flexmeasures/api/v3_0/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from flexmeasures.api.v3_0.health import HealthAPI
3232
from flexmeasures.api.v3_0.public import ServicesAPI
3333
from flexmeasures.api.v3_0.deprecated import SensorEntityAddressAPI
34+
from flexmeasures.api.v3_0.sources import SourceAPI
3435
from flexmeasures.api.v3_0.assets import (
3536
flex_context_schema_openAPI,
3637
AssetAPIQuerySchema,
@@ -58,6 +59,7 @@ def register_at(app: Flask):
5859
HealthAPI.register(app, route_prefix=v3_0_api_prefix)
5960
ServicesAPI.register(app)
6061
SensorEntityAddressAPI.register(app, route_prefix=v3_0_api_prefix)
62+
SourceAPI.register(app, route_prefix=v3_0_api_prefix)
6163

6264
register_swagger_ui(app)
6365

flexmeasures/api/v3_0/sources.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
from __future__ import annotations
2+
3+
from flask_classful import FlaskView, route
4+
from flask_json import as_json
5+
from flask_security import current_user, auth_required
6+
from marshmallow import fields, Schema
7+
from packaging.version import Version, InvalidVersion
8+
from sqlalchemy import select, or_, and_
9+
from webargs.flaskparser import use_kwargs
10+
11+
from flexmeasures.auth.policy import user_has_admin_access, CONSULTANT_ROLE
12+
from flexmeasures.data import db
13+
from flexmeasures.data.models.data_sources import DataSource, DEFAULT_DATASOURCE_TYPES
14+
15+
"""
16+
API endpoint to list accessible data sources and defined source types.
17+
"""
18+
19+
20+
class SourceQuerySchema(Schema):
21+
only_latest = fields.Bool(
22+
load_default=False,
23+
metadata={
24+
"description": (
25+
"If true, return only the most recent version of each source "
26+
"(grouped by name, type and model). Determined by the highest "
27+
"model version string; ties are broken by the highest source id."
28+
)
29+
},
30+
)
31+
32+
33+
def _get_accessible_account_ids() -> list[int] | None:
34+
"""Return account IDs whose sources the current user may read.
35+
36+
Returns None to indicate "all accounts" (admin access).
37+
"""
38+
if user_has_admin_access(current_user, "read"):
39+
return None # all sources
40+
41+
accessible_ids = [current_user.account_id]
42+
if current_user.has_role(CONSULTANT_ROLE):
43+
for client_account in current_user.account.consultancy_client_accounts:
44+
accessible_ids.append(client_account.id)
45+
return accessible_ids
46+
47+
48+
def _filter_sources_to_latest(sources: list[DataSource]) -> list[DataSource]:
49+
"""Keep only the highest-versioned DataSource per (name, type, model) group.
50+
51+
When two sources share the same version (or both have no version), the one
52+
with the higher id wins.
53+
"""
54+
55+
def _version_key(source: DataSource):
56+
try:
57+
return Version(source.version or "0.0.0")
58+
except InvalidVersion:
59+
return Version("0.0.0")
60+
61+
best: dict[tuple, DataSource] = {}
62+
for source in sources:
63+
key = (source.name, source.type, source.model)
64+
if key not in best:
65+
best[key] = source
66+
else:
67+
existing = best[key]
68+
if (_version_key(source), source.id) > (
69+
_version_key(existing),
70+
existing.id,
71+
):
72+
best[key] = source
73+
return list(best.values())
74+
75+
76+
class SourceAPI(FlaskView):
77+
route_base = "/sources"
78+
trailing_slash = False
79+
decorators = [auth_required()]
80+
81+
@route("", methods=["GET"])
82+
@use_kwargs(SourceQuerySchema, location="query")
83+
@as_json
84+
def index(self, only_latest: bool = False):
85+
"""List accessible data sources and defined source types.
86+
87+
.. :quickref: Sources; List accessible data sources and defined source types.
88+
89+
---
90+
get:
91+
summary: List accessible data sources and defined source types.
92+
description: |
93+
Returns the list of data sources accessible to the current user and
94+
the defined source types.
95+
96+
**Access rules:**
97+
98+
- Admins see all data sources.
99+
- Users with the ``consultant`` role see sources belonging to their
100+
own account and to any consultancy-client accounts for which their
101+
account is the consultancy.
102+
- All other authenticated users see only sources belonging to their
103+
own account, plus sources that have neither a ``user_id`` nor an
104+
``account_id`` (i.e. system/public sources).
105+
106+
security:
107+
- ApiKeyAuth: []
108+
parameters:
109+
- in: query
110+
schema: SourceQuerySchema
111+
responses:
112+
200:
113+
description: PROCESSED
114+
content:
115+
application/json:
116+
example:
117+
types:
118+
- user
119+
- scheduler
120+
- forecaster
121+
- reporter
122+
- demo script
123+
- gateway
124+
- market
125+
sources:
126+
- id: 1
127+
name: Seita
128+
type: scheduler
129+
model: StorageScheduler
130+
version: "1.0"
131+
description: "Seita's StorageScheduler model v1.0"
132+
account_id: 2
133+
401:
134+
description: UNAUTHORIZED
135+
403:
136+
description: INVALID_SENDER
137+
tags:
138+
- Sources
139+
"""
140+
accessible_account_ids = _get_accessible_account_ids()
141+
142+
query = select(DataSource)
143+
if accessible_account_ids is not None:
144+
# Sources owned by one of the accessible accounts, OR sources
145+
# with no account_id AND no user_id (system / public sources).
146+
query = query.where(
147+
or_(
148+
DataSource.account_id.in_(accessible_account_ids),
149+
and_(
150+
DataSource.account_id.is_(None),
151+
DataSource.user_id.is_(None),
152+
),
153+
)
154+
)
155+
156+
sources: list[DataSource] = list(db.session.scalars(query).all())
157+
158+
if only_latest:
159+
sources = _filter_sources_to_latest(sources)
160+
161+
serialized = [_serialize_source(s) for s in sources]
162+
163+
# Collect any extra types present in the DB but not in the defaults
164+
db_types = {s.type for s in sources if s.type}
165+
all_types = list(DEFAULT_DATASOURCE_TYPES) + sorted(
166+
db_types - set(DEFAULT_DATASOURCE_TYPES)
167+
)
168+
169+
return {"types": all_types, "sources": serialized}, 200
170+
171+
172+
def _serialize_source(source: DataSource) -> dict:
173+
"""Serialize a DataSource to a plain dict for the API response."""
174+
result = {
175+
"id": source.id,
176+
"name": source.name,
177+
"type": source.type,
178+
"description": source.description,
179+
}
180+
if source.model is not None:
181+
result["model"] = source.model
182+
if source.version is not None:
183+
result["version"] = source.version
184+
if source.account_id is not None:
185+
result["account_id"] = source.account_id
186+
if source.user_id is not None:
187+
result["user_id"] = source.user_id
188+
return result

0 commit comments

Comments
 (0)