Coverage for toardb / timeseries / crud.py: 77%
656 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 12:51 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 12:51 +0000
1# SPDX-FileCopyrightText: 2021 Forschungszentrum Jülich GmbH
2# SPDX-License-Identifier: MIT
4"""
5Create, Read, Update, Delete functionality
7"""
8from sqlalchemy import insert, select, and_, func, text
9from sqlalchemy.orm import Session, load_only
10from sqlalchemy.util import _collections
11from sqlalchemy.exc import IntegrityError
12from geoalchemy2.elements import WKBElement, WKTElement
13from fastapi import File, UploadFile
14from fastapi.responses import JSONResponse
15import datetime as dt
16import json
17from . import models
18from .models import TimeseriesChangelog, timeseries_timeseries_roles_table, \
19 timeseries_timeseries_annotations_table, s1_contributors_table, YearlyCoverage
20from toardb.stationmeta.models import StationmetaCore, StationmetaGlobal
21from toardb.stationmeta.schemas import get_coordinates_from_geom, get_geom_from_coordinates, get_coordinates_from_string
22from toardb.stationmeta.crud import get_stationmeta_by_id, get_stationmeta_core, station_id_exists, get_stationmeta_changelog
23from toardb.contacts.crud import get_organisation_by_name, get_contact
24from toardb.contacts.models import Organisation, Person, Contact
25from toardb.contacts.schemas import ContactBase
26from toardb.variables.models import Variable
27from toardb.variables.crud import get_variable
28from .schemas import TimeseriesCreate, TimeseriesPatch, TimeseriesRoleNoCreate, TimeseriesRoleFields
29from toardb.utils.utils import get_value_from_str, get_str_from_value, create_filter, roles_params
30import toardb
33def clean_additional_metadata(ad_met_dict):
34 # all changes are permanent!
35 if not isinstance(ad_met_dict,dict):
36 tmp = ad_met_dict.replace('"','\\"')
37 return tmp.replace("'",'"')
38 # there is a mismatch with additional_metadata
39 additional_metadata = ad_met_dict
40 for key, value in additional_metadata.items():
41 if isinstance(value,dict):
42 for key2, value2 in value.items():
43 if isinstance(value2,str):
44 additional_metadata[key][key2] = value2.replace("'","$apostroph$")
45 elif isinstance(value,str):
46 additional_metadata[key] = value.replace("'","$apostroph$")
47 additional_metadata = str(additional_metadata).replace('"','\\"')
48 additional_metadata = str(additional_metadata).replace("'",'"')
49 additional_metadata = str(additional_metadata).replace("$apostroph$","'")
50 return additional_metadata
53def get_timeseries(db: Session, timeseries_id: int, fields: str = None):
54 if fields:
55 fields = ','.join('"{}"'.format(word) for word in fields.split(','))
56 # this command returns a tuple (which is unmutable --> problems with additional_metadata and coordinates!)
57 # also this command could not deal with reserved words used as column names (like "order")
58 resultproxy = db.execute(f'SELECT {fields} FROM timeseries WHERE id={timeseries_id} LIMIT 1')
59 db_object_dict = [ rowproxy._asdict() for rowproxy in resultproxy ][0]
60 if fields.find('additional_metadata') != -1:
61 db_object_dict['additional_metadata'] = clean_additional_metadata(db_object_dict['additional_metadata'])
62 db_object = models.Timeseries(**db_object_dict)
63 else:
64 db_object = db.query(models.Timeseries).filter(models.Timeseries.id == timeseries_id).first()
65 if db_object:
66 # only for internal use!
67 if db_object.data_license_accepted:
68 del db_object.data_license_accepted
69 if db_object.dataset_approved_by_provider:
70 del db_object.dataset_approved_by_provider
71 if db_object:
72 try:
73 # there is a mismatch with additional_metadata
74 db_object.additional_metadata = clean_additional_metadata(db_object.additional_metadata)
75 except:
76 pass
77 try:
78 # there is also a mismatch with coordinates and additional_metadata from station object
79 if isinstance(db_object.station.coordinates, (WKBElement, WKTElement)):
80 db_object.station.coordinates = get_coordinates_from_geom(db_object.station.coordinates)
81 # there is a mismatch with additional_metadata
82 if isinstance(db_object.station.additional_metadata, dict):
83 db_object.station.additional_metadata = json.dumps(db_object.station.additional_metadata)
84 except:
85 pass
86 return db_object
88# https://www.reddit.com/r/flask/comments/ypqk40/default_datetimenow_not_using_current_time/
89# dt.datetime.now(dt.timezone.utc) cannot be used as default value, since it will already be
90# evaluated at the start of the worker process and **not** when actually accessing the data
91# def get_citation(db: Session, timeseries_id: int, datetime: dt.datetime = dt.datetime.now(dt.timezone.utc)):
92def get_citation(db: Session, timeseries_id: int, datetime: dt.datetime = None):
93 if not datetime:
94 datetime = dt.datetime.now(dt.timezone.utc)
95 db_object = db.query(models.Timeseries).filter(models.Timeseries.id == timeseries_id).first()
96 # there is a mismatch with additional_metadata
97 PI = "unknown"
98 originators = False
99 attribution = None
100 if db_object:
101 pi_role = get_value_from_str(toardb.toardb.RC_vocabulary,'PrincipalInvestigator')
102 originator_role = get_value_from_str(toardb.toardb.RC_vocabulary,'Originator')
103 contributor_role = get_value_from_str(toardb.toardb.RC_vocabulary,'Contributor')
104 list_of_originators = []
105 for db_role in db_object.roles:
106 if (db_role.role == pi_role):
107 db_contact = get_contact(db, contact_id = db_role.contact_id)
108 PI = db_contact.name
109 elif (db_role.role == originator_role):
110 originators = True
111 db_contact = get_contact(db, contact_id = db_role.contact_id)
112 list_of_originators.append(db_contact.name)
113 elif (db_role.role == contributor_role):
114 db_contact = get_contact(db, contact_id = db_role.contact_id)
115 list_of_originators.append(db_contact.name)
116 list_of_data_originators = ", ".join(list_of_originators)
117 # if no PI is given, the resource provider's longname should be named
118 if PI == "unknown":
119 role = get_value_from_str(toardb.toardb.RC_vocabulary,'ResourceProvider')
120 for db_role in db_object.roles:
121 if (db_role.role == role):
122 db_contact = get_contact(db, contact_id = db_role.contact_id)
123 PI = db_contact.longname
124 if db_contact.attribution != '':
125 attribution = db_contact.attribution
126 var = get_variable(db, variable_id=db_object.variable_id).name
127 station = get_stationmeta_by_id(db, station_id=db_object.station_id).name
128 dataset_version = db_object.provider_version.strip()
129 dataset_doi = db_object.doi.strip()
130 citation = f"{PI}: time series of {var} at {station}, accessed from the TOAR database on {datetime}"
131 if attribution and list_of_data_originators:
132 attribution = attribution.format(orga_or_origs="data originators" if originators else "contributing organisations", list_of_data_originators=list_of_data_originators)
133 if dataset_version != 'N/A':
134 citation += f", original dataset version {dataset_version}"
135 if dataset_doi!= '':
136 citation += f", original dataset doi: {dataset_doi}"
137 license_txt = "This data is published under a Creative Commons Attribution 4.0 International (CC BY 4.0). https://creativecommons.org/licenses/by/4.0/"
138 return {"attribution": attribution, "citation": citation, "license": license_txt}
140def adapt_db_object(db_object, db, fields=False, lconstr_roles=False):
141 if fields:
142 db_object = dict(zip((field for field in fields if field not in {"station_changelog", "changelog"}), db_object))
144 # there is a mismatch with coordinates and additional_metadata
145 if "coordinates" in db_object:
146 db_object["coordinates"] = get_coordinates_from_string(db_object["coordinates"])
148 if "additional_metadata" in db_object:
149 db_object["additional_metadata"] = clean_additional_metadata(db_object["additional_metadata"])
151 if "station_id" in db_object:
152 station_id = {"id": db_object["station_id"]}
153 db_object["station"] = station_id
154 del db_object["station_id"]
156 if "station_additional_metadata" in db_object:
157 cleaned_metadata = clean_additional_metadata(db_object["station_additional_metadata"])
158 if "station" not in db_object:
159 db_object["station"] = {}
160 db_object["station"]["additional_metadata"] = cleaned_metadata
161 del db_object["station_additional_metadata"]
163 if "variable_id" in db_object:
164 variable_id = {"id": db_object["variable_id"]}
165 db_object["variable"] = variable_id
166 del db_object["variable_id"]
168 if "changelog" in db_object:
169 db_object["changelog"] = get_timeseries_changelog(db, db_object["id"])
171 if "station_changelog" in db_object:
172 try:
173 db_object["station_changelog"] = get_stationmeta_changelog(db, db_object["station_id"])
174 except Exception:
175 pass
177 if lconstr_roles:
178 # example, how to put the roles explicitly (not needed at the moment)
179 # organisation = get_contact(db, contact_id=39)
180 # roles_atts["contact"] = {"id": 39, "organisation": organisation.__dict__}
181 roles_atts = {key: value for key, value in db_object.items() if key in roles_params}
182 db_object = {key: value for key, value in db_object.items() if key not in roles_params}
183 db_object["roles"] = TimeseriesRoleFields(**roles_atts)
184 else:
185 if isinstance(db_object.station.coordinates, (WKBElement, WKTElement)):
186 db_object.station.coordinates = get_coordinates_from_geom(db_object.station.coordinates)
187 # there is a mismatch with additional_metadata
188 if isinstance(db_object.station.additional_metadata, dict):
189 db_object.station.additional_metadata = json.dumps(db_object.station.additional_metadata)
190 db_object.additional_metadata = clean_additional_metadata(db_object.additional_metadata)
193 #Internall use
194 try:
195 del db_object.data_license_accepted
196 except AttributeError:
197 pass
199 try:
200 del db_object.dataset_approved_by_provider
201 except AttributeError:
202 pass
204 return db_object
206class TimeseriesQuery:
207 def __init__(self, sign, query, fields, lconstr_roles):
208 self.sign = sign
209 self.query = query
210 self.fields = fields
211 self.lconstr_roles = lconstr_roles
213 @staticmethod
214 def aggregate(querys):
215 aggregated_query = next(querys)
216 for query in querys:
217 if aggregated_query.fields != query.fields:
218 raise ValueError("Fields of subquerys are diffrent")
219 aggregated_query = TimeseriesQuery(
220 True,
221 aggregated_query.query.union(query.query)
222 if query.sign
223 else aggregated_query.query.except_(query.query),
224 aggregated_query.fields,
225 aggregated_query.lconstr_roles or query.lconstr_roles,
226 )
227 return aggregated_query
229 @staticmethod
230 def from_query_params(query_params, db, endpoint="timeseries", sign=True):
231 limit, offset, fields, format, filters = create_filter(query_params, endpoint)
232 t_filter = filters["t_filter"]
233 t_r_filter = filters["t_r_filter"]
234 s_c_filter = filters["s_c_filter"]
235 s_g_filter = filters["s_g_filter"]
237 if fields:
238 # If only certain fields are selected the return type is not a orm object anymore but a dict
239 # sort input fields to be sure to replace station_changelog before changelog
240 fields = sorted(fields.split(","), reverse=True)
241 if "role" in fields:
242 fields.remove("role")
243 fields.extend(roles_params)
245 field_map = {
246 "id": models.Timeseries.id,
247 "order": models.Timeseries.order,
248 "additional_metadata": models.Timeseries.additional_metadata,
249 "station_id": StationmetaCore.id,
250 "variable_id": Variable.id,
251 "name": StationmetaCore.name,
252 "coordinates": func.ST_AsText(StationmetaCore.coordinates),
253 "station_country": StationmetaCore.country,
254 "station_additional_metadata": StationmetaCore.additional_metadata
255 }
257 query_select = [field_map.get(field, text(field)) for field in fields]
259 else:
260 query_select = [models.Timeseries]
262 if 'station_id' in fields and not id in fields:
263 query = (
264 db.query(*query_select)
265 .select_from(models.Timeseries)
266 .distinct()
267 .filter(text(t_filter), text(s_c_filter), text(s_g_filter), text(t_r_filter))
268 .join(StationmetaCore)
269 .join(StationmetaGlobal)
270 .join(timeseries_timeseries_roles_table)
271 .join(models.TimeseriesRole)
272 .join(Contact)
273 .join(Organisation)
274 .join(Person)
275 .join(Variable)
276 .execution_options(stream_results=True)
277 )
278 else:
279 query = (
280 db.query(*query_select)
281 .select_from(models.Timeseries)
282 .distinct()
283 .filter(text(t_filter), text(s_c_filter), text(s_g_filter), text(t_r_filter))
284 .join(StationmetaCore)
285 .join(StationmetaGlobal)
286 .join(timeseries_timeseries_roles_table)
287 .join(models.TimeseriesRole)
288 .join(Contact)
289 .join(Organisation)
290 .join(Person)
291 .join(Variable)
292 .execution_options(stream_results=True)
293 .order_by(models.Timeseries.id)
294 )
296 # Apply NOT filter with role logic
297 if "NOT" in t_r_filter:
298 role_ids = get_role_id_from_string(db, query_params.get("has_role")[1:])
299 query = query.filter(
300 ~models.Timeseries.id.in_(
301 select(timeseries_timeseries_roles_table.c.timeseries_id).where(
302 timeseries_timeseries_roles_table.c.role_id.in_(role_ids)
303 )
304 )
305 )
307 if limit:
308 query = query.limit(limit).offset(offset)
310 return TimeseriesQuery(sign, query, fields, lconstr_roles=any(field in roles_params for field in fields))
312 def adapt_objects(self, db):
313 return [adapt_db_object(db_object, db, self.fields, self.lconstr_roles) for db_object in self.query]
316def search_all(db, path_params, query_params, lts=False, endpoint=None):
317 if endpoint == None:
318 endpoint = "timeseries" if lts else "search"
320 try:
321 ts_list = TimeseriesQuery.from_query_params(query_params, db, endpoint).adapt_objects(db)
322 # remove duplicates
323 if ts_list and isinstance(ts_list[0], dict):
324 try:
325 ts_set = set(json.dumps(ts, sort_keys=True) for ts in ts_list)
326 return [json.loads(ts) for ts in ts_set]
327 except: # not correct, because duplicates are NOT removed!!!
328 return ts_list
329 else:
330 return ts_list
331 except (KeyError, ValueError) as e:
332 status_code = 400
333 return JSONResponse(status_code=status_code, content=str(e))
336def search_all_aggregation(db, path_params, signs, query_params_list, lts=False):
337 endpoint = "timeseries" if lts else "search"
339 try:
340 ts_list = TimeseriesQuery.aggregate(
341 TimeseriesQuery.from_query_params(query_params, db, endpoint, sign)
342 for sign, query_params in zip(signs, query_params_list)
343 ).adapt_objects(db)
344 # remove duplicates
345 if ts_list and isinstance(ts_list[0], dict):
346 try:
347 ts_set = set(json.dumps(ts, sort_keys=True) for ts in ts_list)
348 return [json.loads(ts) for ts in ts_set]
349 except: # not correct, because duplicates are NOT removed!!!
350 return ts_list
351 else:
352 return ts_list
353 except (KeyError, ValueError) as e:
354 status_code = 400
355 return JSONResponse(status_code=status_code, content=str(e))
359def get_timeseries_by_unique_constraints(db: Session, station_id: int, variable_id: int, resource_provider: str = None,
360 sampling_frequency: str = None, provider_version: str = None, data_origin_type: str = None,
361 data_origin: str = None, sampling_height: float = None, label: str = None):
362 """
363 Criteria taken from TOAR_TG_Vol02_Data_Processing, 'Step 14: Identify Time Series'
364 Criterion 14.1: id of the corresponding station
365 Criterion 14.2: variable id
366 Criterion 14.3: role: resource_provider (organisation)
367 Criterion 14.4: sampling_frequency
368 Criterion 14.5: version number
369 Criterion 14.6: data_origin_type (measurement or model)
370 Criterion 14.7: data origin
371 Criterion 14.8: sampling height
372 Criterion 14.9: data filtering procedures or other special dataset identifiers (use database field 'label')
373 """
375# print("in get_timeseries_by_unique_constraints")
376# print(f"station_id: {station_id}, variable_id: {variable_id}, resource_provider: {resource_provider}, ", \
377# f"sampling_frequency: {sampling_frequency}, provider_version: {provider_version}, data_origin_type: {data_origin_type}, ", \
378# f"data_origin: {sampling_frequency}, sampling_height: {sampling_height}, label: {label}")
380 # filter for criterion 14.1 and 14.2
381 ret_db_object = db.query(models.Timeseries).filter(models.Timeseries.station_id == station_id) \
382 .filter(models.Timeseries.variable_id == variable_id).all()
383 # if already not found: return None
384 # if only one single object is found, it has to be checked whether all criterions are fullfilled
385 if len(ret_db_object) == 0:
386 return None
389 # filter for criterion 14.3
390 if resource_provider:
391 # issue with '/' in organisation longname ==> only possible with double encoding!
392 resource_provider = resource_provider.replace('%2F', '/')
393 role_num = get_value_from_str(toardb.toardb.RC_vocabulary,'ResourceProvider')
394 iter_obj = ret_db_object.copy()
395 counter=0
396 for db_object in iter_obj:
397 found = False
398 for role in db_object.roles:
399 # resource provider is always an organisation!
400 organisation = get_contact(db, contact_id=role.contact_id)
401 if ((role_num == role.role) and (organisation.longname == resource_provider)):
402 found = True
403 if not found:
404 ret_db_object.pop(counter)
405 else:
406 counter += 1
407 else:
408 # time series that do not have a resource_provider are not identical to those who do not!
409 role_num = get_value_from_str(toardb.toardb.RC_vocabulary,'ResourceProvider')
410 iter_obj = ret_db_object.copy()
411 counter=0
412 for db_object in iter_obj:
413 found = False
414 for role in db_object.roles:
415 if (role_num == role.role):
416 found = True
417 if found:
418 ret_db_object.pop(counter)
419 else:
420 counter += 1
423 # if already only none object --> return
424 # if only one single object is found, it has to be checked whether all criterions are fullfilled
425 if len(ret_db_object) == 0:
426 return None
428 # filter for criterion 14.4
429 if sampling_frequency:
430 iter_obj = ret_db_object.copy()
431 counter=0
432 for db_object in iter_obj:
433 if not (db_object.sampling_frequency == sampling_frequency):
434 ret_db_object.pop(counter)
435 else:
436 counter += 1
438 # if already only none object --> return
439 # if only one single object is found, it has to be checked whether all criterions are fullfilled
440 if len(ret_db_object) == 0:
441 return None
443 # filter for criterion 14.5
444 if provider_version:
445 iter_obj = ret_db_object.copy()
446 counter=0
447 for db_object in iter_obj:
448 if not (db_object.provider_version == provider_version):
449 ret_db_object.pop(counter)
450 else:
451 counter += 1
453 # if already only none object --> return
454 # if only one single object is found, it has to be checked whether all criterions are fullfilled
455 if len(ret_db_object) == 0:
456 return None
458 # filter for criterion 14.6
459 if data_origin_type:
460 data_origin_type_num = get_value_from_str(toardb.toardb.OT_vocabulary,data_origin_type)
461 iter_obj = ret_db_object.copy()
462 counter=0
463 for db_object in iter_obj:
464 if not (db_object.data_origin_type == data_origin_type_num):
465 ret_db_object.pop(counter)
466 else:
467 counter += 1
469 # if already only none object --> return
470 # if only one single object is found, it has to be checked whether all criterions are fullfilled
471 if len(ret_db_object) == 0:
472 return None
474 # filter for criterion 14.7
475 if data_origin:
476 data_origin_num = get_value_from_str(toardb.toardb.DO_vocabulary,data_origin)
477 iter_obj = ret_db_object.copy()
478 counter=0
479 for db_object in iter_obj:
480 if not (db_object.data_origin == data_origin_num):
481 ret_db_object.pop(counter)
482 else:
483 counter += 1
485 # if already only none object --> return
486 # if only one single object is found, it has to be checked whether all criterions are fullfilled
487 if len(ret_db_object) == 0:
488 return None
490 # filter for criterion 14.8
491 if sampling_height:
492 iter_obj = ret_db_object.copy()
493 counter=0
494 for db_object in iter_obj:
495 if not (db_object.sampling_height == sampling_height):
496 ret_db_object.pop(counter)
497 else:
498 counter += 1
500 # if already only none object --> return
501 # if only one single object is found, it has to be checked whether all criterions are fullfilled
502 if len(ret_db_object) == 0:
503 return None
505 # filter for criterion 14.9
506 if label:
507 iter_obj = ret_db_object.copy()
508 counter=0
509 for db_object in iter_obj:
510 if not (db_object.label == label):
511 ret_db_object.pop(counter)
512 else:
513 counter += 1
515 # check that only one object is left!!!
516 # adapt mismatches for return value
517 if len(ret_db_object) == 0:
518 ret_db_object = None
519 else:
520 if len(ret_db_object) == 1:
521 ret_db_object = ret_db_object[0]
522 # there is a mismatch with additional_metadata
523 ret_db_object.additional_metadata = clean_additional_metadata(ret_db_object.additional_metadata)
524 # there is also a mismatch with coordinates and additional_metadata from station object
525 if isinstance(ret_db_object.station.coordinates, (WKBElement, WKTElement)):
526 ret_db_object.station.coordinates = get_coordinates_from_geom(ret_db_object.station.coordinates)
527 # there is a mismatch with additional_metadata
528 if isinstance(ret_db_object.station.additional_metadata, dict):
529 ret_db_object.station.additional_metadata = json.dumps(ret_db_object.station.additional_metadata)
530 else:
531 status_code=405
532 message=f"Timeseries not unique, more criteria need to be defined."
533 return JSONResponse(status_code=status_code, content=message)
535 return ret_db_object
538def get_timeseries_changelog(db: Session, timeseries_id: int):
539 return db.query(models.TimeseriesChangelog).filter(models.TimeseriesChangelog.timeseries_id == timeseries_id).all()
542def get_timeseries_programme(db: Session, name: str):
543 return db.query(models.TimeseriesProgramme).filter(models.TimeseriesProgramme.name == name).all()
546# is this internal, or should this also go to public REST api?
547# do we need this at all?
548def get_role_ids_of_timeseries(db: Session, timeseries_id: int):
549 db_objects = db.query(models.TimeseriesTimeseriesRoles) \
550 .filter(models.TimeseriesTimeseriesRoles.timeseries_id == timeseries_id) \
551 .all()
552 return db_objects
555# is this internal, or should this also go to public REST api?
556def get_unique_timeseries_role(db: Session, role: int, contact_id: int, status: int):
557 db_object = db.query(models.TimeseriesRole).filter(models.TimeseriesRole.role == role) \
558 .filter(models.TimeseriesRole.contact_id == contact_id) \
559 .filter(models.TimeseriesRole.status == status) \
560 .first()
561 return db_object
564def get_role_id_from_string(db: Session, role_string: str):
565 sql_command = f"""
566 SELECT distinct(r.id) FROM timeseries_roles r,
567 contacts c,
568 organisations o,
569 persons p
570 WHERE (o.longname IN ('{role_string}') OR
571 o.name IN ('{role_string}') OR
572 o.city IN ('{role_string}') OR
573 o.homepage IN ('{role_string}') OR
574 o.contact_url IN ('{role_string}') OR
575 p.email IN ('{role_string}') OR
576 p.name IN ('{role_string}') OR
577 p.orcid IN ('{role_string}')) AND
578 r.contact_id=c.id AND
579 c.person_id=p.id AND
580 c.organisation_id=o.id
581 """
582 resultproxy = db.execute(sql_command)
583 id_list = [ rowproxy._asdict()['id'] for rowproxy in resultproxy ]
584 return id_list
586# is this internal, or should this also go to public REST api?
587def get_unique_timeseries_programme(db: Session, name: str, homepage: str):
588 db_object = db.query(models.TimeseriesProgramme).filter(models.TimeseriesProgramme.name == name) \
589 .filter(models.TimeseriesProgramme.homepage == homepage) \
590 .first()
591 return db_object
594# is this internal, or should this also go to public REST api?
595def get_unique_timeseries_annotation(db: Session, text: str, contributor_id: int):
596 db_object = db.query(models.TimeseriesAnnotation).filter(models.TimeseriesAnnotation.text == text) \
597 .filter(models.TimeseriesAnnotation.contributor_id == contributor_id) \
598 .first()
599 return db_object
602def get_contributors_string(programmes, roles):
603 # sort every section alphabetically and have sub-section titles
604 # programmes are already unique, but they still need to be sorted
605 resultp = "programmes: " + ";".join(sorted([programme.longname for programme in programmes])) if len(programmes) > 0 else ''
606 # organisations might contain duplicates
607 organisations = set()
608 [ organisations.add(role.contact.organisation.longname) for role in roles ]
609 # eliminate dummy organisation
610 organisations.discard('')
611 resulto = "organisations: " + ";".join(sorted(organisations)) if len(organisations) > 0 else ''
612 # persons might contain duplicates
613 persons = set()
614 [ persons.add(role.contact.person.name) for role in roles ]
615 # eliminate dummy person
616 persons.discard('')
617 resultc = "persons:" + ";".join(sorted(persons)) if len(persons) > 0 else ''
618 result = resultp if resultp != '' else ''
619 if resulto != '':
620 result = resulto if result == '' else result + '; ' + resulto
621 if resultc != '':
622 result = resultc if result == '' else result + '; ' + resultc
623 return result
626def get_contributors_list(db: Session, timeseries_ids, format: str = 'text'):
627 # get time series' programmes
628 # join(models.Timeseries, Timeseries.programme_id == TimeseriesProgramme.id) is implicit given due to the foreign key
629 programmes = db.query(models.TimeseriesProgramme) \
630 .join(models.Timeseries) \
631 .filter(models.Timeseries.id.in_(timeseries_ids), models.TimeseriesProgramme.id != 0).all()
632 # get all time series roles (with duplicates)
633 roles = db.execute(select([timeseries_timeseries_roles_table]).where(timeseries_timeseries_roles_table.c.timeseries_id.in_(timeseries_ids)))
634 # eliminate duplicates
635 role_ids = set()
636 [ role_ids.add(role.role_id) for role in roles ]
637 roles = db.query(models.TimeseriesRole).filter(models.TimeseriesRole.id.in_(role_ids)).all()
638 # return both programmes and roles
639 if format == 'text':
640 result = get_contributors_string(programmes, roles)
641 elif format == 'json':
642 result = programmes + roles
643 else:
644 status_code=400
645 message=f"not a valid format: {format}"
646 result = JSONResponse(status_code=status_code, content=message)
647 return result
650def get_request_contributors(db: Session, format: str = 'text', input_handle: UploadFile = File(...)):
651 f = input_handle.file
652 timeseries_ids = [int(line.strip()) for line in f.readlines()]
653 return get_contributors_list(db, timeseries_ids, format)
656def get_registered_request_contributors(db: Session, rid, format: str = 'text'):
657 try:
658 timeseries_ids = db.execute(select([s1_contributors_table]).\
659 where(s1_contributors_table.c.request_id == rid)).mappings().first()['timeseries_ids']
660 return get_contributors_list(db, timeseries_ids, format)
661 except:
662 status_code=400
663 message=f"not a registered request id: {rid}"
664 return JSONResponse(status_code=status_code, content=message)
667def register_request_contributors(db: Session, rid, ids):
668 try:
669 db.execute(insert(s1_contributors_table).values(request_id=rid, timeseries_ids=ids))
670 db.commit()
671 status_code = 200
672 message=f'{rid} successfully registered.'
673 except IntegrityError as e:
674 error_code = e.orig.pgcode
675 if error_code == "23505":
676 status_code = 443
677 message=f'{rid} already registered.'
678 else:
679 status_code = 442
680 message=f'database error: error_code'
681 result = JSONResponse(status_code=status_code, content=message)
682 return result
685# is this internal, or should this also go to public REST api?
686def get_timeseries_roles(db: Session, timeseries_id: int):
687 return db.execute(select([timeseries_timeseries_roles_table]).where(timeseries_timeseries_roles_table.c.timeseries_id == timeseries_id))
690# is this internal, or should this also go to public REST api?
691def get_timeseries_role_by_id(db: Session, role_id):
692 return db.query(models.TimeseriesRole).filter(models.TimeseriesRole.id == role_id).first()
695# is this internal, or should this also go to public REST api?
696def get_timeseries_annotations(db: Session, timeseries_id: int):
697 return db.execute(select([timeseries_timeseries_annotations_table]).where(timeseries_timeseries_annotations_table.c.timeseries_id == timeseries_id))
700# is this internal, or should this also go to public REST api?
701def get_timeseries_annotation_by_id(db: Session, annotation_id):
702 return db.query(models.TimeseriesAnnotation).filter(models.TimeseriesAnnotation.id == annotation_id).first()
705def list_coverage(db,
706 station_id: int = None,
707 variable_id: int = None,
708 timeseries_id: int = None,
709 year: int = None,
710 year_min: int = None,
711 year_max: int = None,
712 offset: int = 0,
713 limit: int = 10,
714):
715 """Return rows with optional filtering and pagination."""
716 q = db.query(models.YearlyCoverage)
718 if station_id is not None:
719 q = q.filter(models.YearlyCoverage.station_id == station_id)
720 if variable_id is not None:
721 q = q.filter(models.YearlyCoverage.variable_id == variable_id)
722 if timeseries_id is not None:
723 q = q.filter(models.YearlyCoverage.timeseries_id == timeseries_id)
724 if year is not None:
725 q = q.filter(models.YearlyCoverage.year == year)
726 if year_min is not None:
727 q = q.filter(models.YearlyCoverage.year >= year_min)
728 if year_max is not None:
729 q = q.filter(models.YearlyCoverage.year <= year_max)
731 return q.offset(offset).limit(limit).all()
734def create_timeseries(db: Session, timeseries: TimeseriesCreate, author_id: int):
735 timeseries_dict = timeseries.dict()
736 # no timeseries can be created, if station_id or variable_id are not found in the database
737 if not station_id_exists(db,timeseries.station_id):
738 status_code=440
739 message=f"Station (station_id: {timeseries.station_id}) not found in database."
740 return JSONResponse(status_code=status_code, content=message)
741 if timeseries_dict['additional_metadata']:
742 for key, value in timeseries_dict['additional_metadata'].items():
743 if isinstance(value,dict):
744 for key2, value2 in value.items():
745 timeseries_dict['additional_metadata'][key][key2] = value2.replace("''","'")
746 else:
747 timeseries_dict['additional_metadata'][key] = value.replace("''","'")
748 if 'absorption_cross_section' in timeseries_dict['additional_metadata']:
749 value = get_value_from_str(toardb.toardb.CS_vocabulary,timeseries_dict['additional_metadata']['absorption_cross_section'])
750 timeseries_dict['additional_metadata']['absorption_cross_section'] = value
751 if 'calibration_type' in timeseries_dict['additional_metadata']:
752 value = get_value_from_str(toardb.toardb.CT_vocabulary,timeseries_dict['additional_metadata']['calibration_type'])
753 timeseries_dict['additional_metadata']['calibration_type'] = value
754 if 'sampling_type' in timeseries_dict['additional_metadata']:
755 value = get_value_from_str(toardb.toardb.KS_vocabulary,timeseries_dict['additional_metadata']['sampling_type'])
756 timeseries_dict['additional_metadata']['sampling_type'] = value
757 db_variable = get_variable(db,timeseries.variable_id)
758 if not db_variable:
759 status_code=441
760 message=f"Variable (variable_id: {timeseries.variable_id}) not found in database."
761 return JSONResponse(status_code=status_code, content=message)
762 # for networks: we do not want data without a resource_provider (organisation)
763 resource_provider = None
764 if timeseries.roles:
765 for role in timeseries.roles:
766 if role.role == 'ResourceProvider':
767 # resource provider is always an organisation!
768 organisation = get_contact(db, contact_id=role.contact_id)
769 if organisation:
770 resource_provider=organisation.longname
771 else:
772 status_code=442
773 message=f"Resource provider (contact_id: {role.contact_id}) not found in database."
774 return JSONResponse(status_code=status_code, content=message)
775 db_timeseries = get_timeseries_by_unique_constraints(db, station_id=timeseries.station_id,
776 variable_id=timeseries.variable_id, label=timeseries.label,
777 provider_version=timeseries.provider_version, resource_provider=resource_provider)
778 if db_timeseries:
779 if isinstance(db_timeseries, list):
780 status_code=444
781 message = {"detail":{"message":"Given constraints match more than one timeseries.",
782 "timeseries_ids": [ db_timeseries[i].id for i in range(len(db_timeseries)) ]}}
783 return JSONResponse(status_code=status_code, content=message)
784 else:
785 status_code=443
786 message = {"detail":{"message":"Timeseries already registered.","timeseries_id":db_timeseries.id}}
787 return JSONResponse(status_code=status_code, content=message)
788 roles_data = timeseries_dict.pop('roles', None)
789 annotations_data = timeseries_dict.pop('annotations', None)
790 db_timeseries = models.Timeseries(**timeseries_dict)
791 # there is a mismatch with additional_metadata
792 # in upload command, we have now: "additional_metadata": "{}"
793 # but return from this method gives (=database): "additional_metadata": {}
794# print(db_timeseries.additional_metadata)
795# db_timeseries.additional_metadata = json.loads(str(db_timeseries.additional_metadata).replace("'",'"'))
796 db_timeseries.sampling_frequency = get_value_from_str(toardb.toardb.SF_vocabulary,db_timeseries.sampling_frequency)
797 db_timeseries.aggregation = get_value_from_str(toardb.toardb.AT_vocabulary,db_timeseries.aggregation)
798 db_timeseries.data_origin_type = get_value_from_str(toardb.toardb.OT_vocabulary,db_timeseries.data_origin_type)
799 db_timeseries.data_origin = get_value_from_str(toardb.toardb.DO_vocabulary,db_timeseries.data_origin)
800 db.add(db_timeseries)
801 result = db.commit()
802 db.refresh(db_timeseries)
803 # get timeseries_id
804 timeseries_id = db_timeseries.id
805 # store roles and update association table
806 if roles_data:
807 for r in roles_data:
808 db_role = models.TimeseriesRole(**r)
809 db_role.role = get_value_from_str(toardb.toardb.RC_vocabulary,db_role.role)
810 db_role.status = get_value_from_str(toardb.toardb.RS_vocabulary,db_role.status)
811 db_object = get_unique_timeseries_role(db, db_role.role, db_role.contact_id, db_role.status)
812 if db_object:
813 role_id = db_object.id
814 else:
815 # Something is going wrong here!
816 # Is the model TimeseriesRole correctly defined?!
817 del db_role.contact
818 db.add(db_role)
819 db.commit()
820 db.refresh(db_role)
821 role_id = db_role.id
822 db.execute(insert(timeseries_timeseries_roles_table).values(timeseries_id=timeseries_id, role_id=role_id))
823 db.commit()
824 # store annotations and update association table
825 if annotations_data:
826 for a in annotations_data:
827 db_annotation = models.TimeseriesAnnotation(**a)
828 # check whether annotation is already present in database
829 db_object = get_unique_timeseries_annotation(db, db_annotation.text, db_annotation.contributor_id)
830 if db_object:
831 annotation_id = db_object.id
832 else:
833 db.add(db_annotation)
834 db.commit()
835 db.refresh(db_annotation)
836 annotation_id = db_annotation.id
837 db.execute(insert(timeseries_timeseries_annotations_table).values(timeseries_id=timeseries_id, annotation_id=annotation_id))
838 db.commit()
839 # create changelog entry
840 type_of_change = get_value_from_str(toardb.toardb.CL_vocabulary,"Created")
841 description="time series created"
842 db_changelog = TimeseriesChangelog(description=description, timeseries_id=timeseries_id, author_id=author_id, type_of_change=type_of_change,
843 old_value='', new_value='')
844 db.add(db_changelog)
845 db.commit()
846 status_code=200
847 message = {"detail":{"message":"New timeseries created.","timeseries_id":db_timeseries.id}}
848 return JSONResponse(status_code=status_code, content=message)
849# return db_timeseries
852def patch_timeseries(db: Session, description: str, timeseries_id: int, timeseries: TimeseriesPatch,
853 author_id: int):
854 timeseries_dict = timeseries.dict()
855 # check for controlled vocabulary in additional metadata
856 # do this in dictionary that always contains additional_metadata entry
857 if timeseries_dict['additional_metadata']:
858 for key, value in timeseries_dict['additional_metadata'].items():
859 if isinstance(value,dict):
860 for key2, value2 in value.items():
861 timeseries_dict['additional_metadata'][key][key2] = value2.replace("''","'")
862 else:
863 timeseries_dict['additional_metadata'][key] = value.replace("''","'")
864 if 'absorption_cross_section' in timeseries_dict['additional_metadata']:
865 value = get_value_from_str(toardb.toardb.CS_vocabulary,timeseries_dict['additional_metadata']['absorption_cross_section'])
866 timeseries_dict['additional_metadata']['absorption_cross_section'] = value
867 if 'calibration_type' in timeseries_dict['additional_metadata']:
868 value = get_value_from_str(toardb.toardb.CT_vocabulary,timeseries_dict['additional_metadata']['calibration_type'])
869 timeseries_dict['additional_metadata']['calibration_type'] = value
870 if 'sampling_type' in timeseries_dict['additional_metadata']:
871 value = get_value_from_str(toardb.toardb.KS_vocabulary,timeseries_dict['additional_metadata']['sampling_type'])
872 timeseries_dict['additional_metadata']['sampling_type'] = value
873 # delete empty fields from timeseries_dict already at this place, to be able to
874 # distinguish between "single value correction in metadata" and "comprehensive metadata revision"
875 # (see controlled vocabulary "CL_vocabulary")
876 roles_data = timeseries_dict.pop('roles', None)
877 annotations_data = timeseries_dict.pop('annotations', None)
878 timeseries_dict2 = {k: v for k, v in timeseries_dict.items() if v is not None}
879 number_of_elements = len(timeseries_dict2)
880 if roles_data:
881 number_of_elements +=1
882 if annotations_data:
883 number_of_elements +=1
884 if (number_of_elements == 1):
885 type_of_change = get_value_from_str(toardb.toardb.CL_vocabulary,"SingleValue")
886 else:
887 type_of_change = get_value_from_str(toardb.toardb.CL_vocabulary,"Comprehensive")
888 db_obj = models.Timeseries(**timeseries_dict2)
889# also the sqlalchemy get will call get_timeseries here!!!
890# --> therefore call it right away
891 db_timeseries = get_timeseries(db, timeseries_id, fields="dataset_approved_by_provider,data_license_accepted")
892 dataset_approved_by_provider = db_timeseries.dataset_approved_by_provider
893 data_license_accepted = db_timeseries.data_license_accepted
894 db_timeseries = get_timeseries(db, timeseries_id)
895 # for some unknown reasons, format of coordinates and additional_metadata (for timeseries, but also for its related station!) have changed!
896 db.rollback()
897 db_timeseries.dataset_approved_by_provider = dataset_approved_by_provider
898 db_timeseries.data_license_accepted = data_license_accepted
899 # still problems with coordinates and additional metadata (from STATION!!!)...
900 try:
901 db_timeseries.station.coordinates = get_geom_from_coordinates(db_timeseries.station.coordinates)
902 except:
903 pass
904# try:
905# db_timeseries.station.additional_metadata = json.loads(str(db_timeseries.station.additional_metadata).replace("'",'"'))
906# except:
907# pass
908 # prepare changelog entry/entries
909 no_log = (description == 'NOLOG')
910 if not no_log:
911 old_values={}
912 new_values={}
913 for k, v in timeseries_dict2.items():
914 field=str(getattr(db_timeseries,k))
915 if k == 'additional_metadata':
916 old_values[k] = db_timeseries.additional_metadata
917 else:
918 if k == "sampling_frequency":
919 old_values[k] = get_str_from_value(toardb.toardb.SF_vocabulary, int(field))
920 elif k == "aggregation":
921 old_values[k] = get_str_from_value(toardb.toardb.AT_vocabulary, int(field))
922 elif k == "data_origin":
923 old_values[k] = get_str_from_value(toardb.toardb.DO_vocabulary, int(field))
924 elif k == "data_origin_type":
925 old_values[k] = get_str_from_value(toardb.toardb.OT_vocabulary, int(field))
926 else:
927 old_values[k] = field
928 for k, v in timeseries_dict2.items():
929 setattr(db_timeseries,k,timeseries_dict[k])
930 # there is a mismatch with additional_metadata
931 # in upload command, we have now: "additional_metadata": "{}"
932 # but return from this method gives (=database): "additional_metadata": {}
933# if timeseries_dict['additional_metadata']:
934# db_timeseries.additional_metadata = clean_additional_metadata(db_timeseries.additional_metadata)
935 # do the following for every entry that uses the controlled vocabulary!
936 # this should be improved!
937 if db_obj.sampling_frequency:
938 db_timeseries.sampling_frequency = get_value_from_str(toardb.toardb.SF_vocabulary, db_obj.sampling_frequency)
939 if db_obj.aggregation:
940 db_timeseries.aggregation = get_value_from_str(toardb.toardb.AT_vocabulary, db_obj.aggregation)
941 if db_obj.data_origin:
942 db_timeseries.data_origin = get_value_from_str(toardb.toardb.DO_vocabulary, db_obj.data_origin)
943 if db_obj.data_origin_type:
944 db_timeseries.data_origin_type = get_value_from_str(toardb.toardb.OT_vocabulary, db_obj.data_origin_type)
945 db.add(db_timeseries)
946 result = db.commit()
947 # store roles and update association table
948 if roles_data:
949 if not no_log:
950 # prepare changelog entry/entries
951 description = description + f"; add role"
952 db_old_roles = get_timeseries_roles(db, timeseries_id)
953 old_roles = []
954 for oldr in db_old_roles:
955 old_role = get_timeseries_role_by_id(db, oldr.role_id)
956 old_value = {}
957 old_value['role'] = get_str_from_value(toardb.toardb.RC_vocabulary,old_role.role)
958 old_value['status'] = get_str_from_value(toardb.toardb.RS_vocabulary,old_role.status)
959 old_value['contact_id'] = old_role.contact_id
960 old_roles.append(old_value)
961 old_values['roles'] = old_roles
962 new_roles = old_roles.copy()
963 for r in roles_data:
964 db_role = models.TimeseriesRole(**r)
965 if not no_log:
966 new_roles.append(r)
967 db_role.role = get_value_from_str(toardb.toardb.RC_vocabulary,db_role.role)
968 db_role.status = get_value_from_str(toardb.toardb.RS_vocabulary,db_role.status)
969 # check whether role is already present in database
970 db_object = get_unique_timeseries_role(db, db_role.role, db_role.contact_id, db_role.status)
971 if db_object:
972 role_id = db_object.id
973 else:
974 db.add(db_role)
975 db.commit()
976 db.refresh(db_role)
977 role_id = db_role.id
978 db.execute(insert(timeseries_timeseries_roles_table).values(timeseries_id=timeseries_id, role_id=role_id))
979 db.commit()
980 if not no_log:
981 new_values['roles'] = new_roles
982 # store annotations and update association table
983 if annotations_data:
984 if not no_log:
985 # prepare changelog entry/entries
986 description = description + f"; add annotation"
987 db_old_annotations = get_timeseries_annotations(db, timeseries_id)
988 old_annotations = []
989 for olda in db_old_annotations:
990 old_annotation = get_timeseries_annotation_by_id(db, olda.annotation_id)
991 old_value = {}
992 old_value['kind'] = get_str_from_value(toardb.toardb.RC_vocabulary,old_role.kind)
993 old_value['text'] = get_str_from_value(toardb.toardb.RS_vocabulary,old_role.status)
994 old_value['date_added'] = get_str_from_value(toardb.toardb.RS_vocabulary,old_role.status)
995 old_value['approved'] = get_str_from_value(toardb.toardb.RS_vocabulary,old_role.status)
996 old_value['contributor_id'] = str(old_role.contact_id)
997 old_annotations.append(old_value)
998 old_values['annotations'] = old_annotations
999 new_annotations = []
1000 for a in annotations_data:
1001 db_annotation = models.TimeseriesAnnotation(**a)
1002 # check whether annotation is already present in database
1003 if not no_log:
1004 new_annotations.append(a)
1005 db_annotation.kind = get_value_from_str(toardb.toardb.AK_vocabulary,db_annotation.kind)
1006 db_object = get_unique_timeseries_annotation(db, db_annotation.text, db_annotation.contributor_id)
1007 if db_object:
1008 annotation_id = db_object.id
1009 else:
1010 db.add(db_annotation)
1011 db.commit()
1012 db.refresh(db_annotation)
1013 annotation_id = db_annotation.id
1014 db.execute(insert(timeseries_timeseries_annotations_table).values(timeseries_id=timeseries_id, annotation_id=annotation_id))
1015 db.commit()
1016 if not no_log:
1017 new_values['annotations'] = new_annotations
1018 # add patch to changelog table
1019 if not no_log:
1020 if new_values:
1021 timeseries_dict2.update(new_values)
1022 db_changelog = TimeseriesChangelog(description=description, timeseries_id=timeseries_id, author_id=author_id, type_of_change=type_of_change,
1023 old_value=str(old_values), new_value=str(timeseries_dict2))
1024 db.add(db_changelog)
1025 db.commit()
1026 status_code=200
1027 message = {"detail":{"message":"timeseries patched.","timeseries_id":db_timeseries.id}}
1028 return JSONResponse(status_code=status_code, content=message)