cornflow 1.1.0a1__py3-none-any.whl → 1.1.1a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. cornflow/app.py +4 -0
  2. cornflow/cli/utils.py +1 -1
  3. cornflow/config.py +10 -2
  4. cornflow/endpoints/__init__.py +28 -19
  5. cornflow/endpoints/example_data.py +64 -13
  6. cornflow/endpoints/execution.py +1 -1
  7. cornflow/endpoints/login.py +26 -6
  8. cornflow/endpoints/reports.py +283 -0
  9. cornflow/migrations/versions/83164be03c23_.py +40 -0
  10. cornflow/migrations/versions/96f00d0961d1_reports_table.py +50 -0
  11. cornflow/models/__init__.py +2 -0
  12. cornflow/models/execution.py +8 -0
  13. cornflow/models/meta_models.py +23 -12
  14. cornflow/models/reports.py +119 -0
  15. cornflow/schemas/example_data.py +7 -2
  16. cornflow/schemas/execution.py +3 -0
  17. cornflow/schemas/reports.py +48 -0
  18. cornflow/shared/const.py +21 -0
  19. cornflow/shared/exceptions.py +20 -9
  20. cornflow/static/v1.json +3854 -0
  21. cornflow/tests/const.py +7 -0
  22. cornflow/tests/{custom_liveServer.py → custom_live_server.py} +3 -1
  23. cornflow/tests/custom_test_case.py +2 -3
  24. cornflow/tests/integration/test_commands.py +1 -1
  25. cornflow/tests/integration/test_cornflowclient.py +116 -28
  26. cornflow/tests/unit/test_alarms.py +22 -9
  27. cornflow/tests/unit/test_cli.py +10 -5
  28. cornflow/tests/unit/test_commands.py +6 -2
  29. cornflow/tests/unit/test_example_data.py +83 -13
  30. cornflow/tests/unit/test_executions.py +5 -0
  31. cornflow/tests/unit/test_main_alarms.py +8 -0
  32. cornflow/tests/unit/test_reports.py +308 -0
  33. cornflow/tests/unit/test_users.py +5 -2
  34. {cornflow-1.1.0a1.dist-info → cornflow-1.1.1a1.dist-info}/METADATA +31 -31
  35. {cornflow-1.1.0a1.dist-info → cornflow-1.1.1a1.dist-info}/RECORD +38 -31
  36. {cornflow-1.1.0a1.dist-info → cornflow-1.1.1a1.dist-info}/WHEEL +1 -1
  37. {cornflow-1.1.0a1.dist-info → cornflow-1.1.1a1.dist-info}/entry_points.txt +0 -0
  38. {cornflow-1.1.0a1.dist-info → cornflow-1.1.1a1.dist-info}/top_level.txt +0 -0
cornflow/app.py CHANGED
@@ -46,6 +46,9 @@ def create_app(env_name="development", dataconn=None):
46
46
  :return: the application that is going to be running :class:`Flask`
47
47
  :rtype: :class:`Flask`
48
48
  """
49
+ if os.getenv("FLASK_ENV", None) is not None:
50
+ env_name = os.getenv("FLASK_ENV")
51
+
49
52
  dictConfig(log_config(app_config[env_name].LOG_LEVEL))
50
53
 
51
54
  app = Flask(__name__)
@@ -74,6 +77,7 @@ def create_app(env_name="development", dataconn=None):
74
77
  api = Api(app)
75
78
  for res in resources:
76
79
  api.add_resource(res["resource"], res["urls"], endpoint=res["endpoint"])
80
+
77
81
  if app.config["ALARMS_ENDPOINTS"]:
78
82
  for res in alarms_resources:
79
83
  api.add_resource(res["resource"], res["urls"], endpoint=res["endpoint"])
cornflow/cli/utils.py CHANGED
@@ -6,7 +6,7 @@ import warnings
6
6
 
7
7
  def get_app():
8
8
  env = os.getenv("FLASK_ENV", "development")
9
- data_conn = os.getenv("DATABASE_URL", "sqlite:///cornflow.db")
9
+ data_conn = os.getenv("DATABASE_URL")
10
10
  if env == "production":
11
11
  warnings.filterwarnings("ignore")
12
12
  external = int(os.getenv("EXTERNAL_APP", 0))
cornflow/config.py CHANGED
@@ -22,6 +22,14 @@ class DefaultConfig(object):
22
22
  SIGNUP_ACTIVATED = int(os.getenv("SIGNUP_ACTIVATED", 1))
23
23
  CORNFLOW_SERVICE_USER = os.getenv("CORNFLOW_SERVICE_USER", "service_user")
24
24
 
25
+ # file support for reports
26
+ FILE_BACKEND = os.getenv("FILE_BACKEND", "local")
27
+ UPLOAD_FOLDER = os.getenv(
28
+ "UPLOAD_FOLDER",
29
+ os.path.abspath(os.path.join(os.path.dirname(__file__), "./static")),
30
+ )
31
+ ALLOWED_EXTENSIONS = os.getenv("ALLOWED_EXTENSIONS", ["pdf", "html"])
32
+
25
33
  # Open deployment (all dags accessible to all users)
26
34
  OPEN_DEPLOYMENT = os.getenv("OPEN_DEPLOYMENT", 1)
27
35
 
@@ -84,7 +92,6 @@ class DefaultConfig(object):
84
92
 
85
93
 
86
94
  class Development(DefaultConfig):
87
-
88
95
  """ """
89
96
 
90
97
  ENV = "development"
@@ -95,7 +102,7 @@ class Testing(DefaultConfig):
95
102
 
96
103
  ENV = "testing"
97
104
  SQLALCHEMY_TRACK_MODIFICATIONS = False
98
- DEBUG = False
105
+ DEBUG = True
99
106
  TESTING = True
100
107
  PROPAGATE_EXCEPTIONS = True
101
108
  SECRET_TOKEN_KEY = "TESTINGSECRETKEY"
@@ -119,6 +126,7 @@ class Production(DefaultConfig):
119
126
  # needs to be on to avoid getting only 500 codes:
120
127
  # and https://medium.com/@johanesriandy/flask-error-handler-not-working-on-production-mode-3adca4c7385c
121
128
  PROPAGATE_EXCEPTIONS = True
129
+ UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER", "/usr/src/app/static")
122
130
 
123
131
 
124
132
  app_config = {"development": Development, "testing": Testing, "production": Production}
@@ -4,8 +4,8 @@ All references to endpoints should be imported from here
4
4
  The login resource gets created on app startup as it depends on configuration
5
5
  """
6
6
  from .action import ActionListEndpoint
7
+ from .alarms import AlarmsEndpoint
7
8
  from .apiview import ApiViewListEndpoint
8
-
9
9
  from .case import (
10
10
  CaseEndpoint,
11
11
  CaseFromInstanceExecutionEndpoint,
@@ -15,7 +15,6 @@ from .case import (
15
15
  CaseToInstance,
16
16
  CaseCompare,
17
17
  )
18
-
19
18
  from .dag import (
20
19
  DAGDetailEndpoint,
21
20
  DAGEndpointManual,
@@ -24,7 +23,12 @@ from .dag import (
24
23
  DeployedDAGEndpoint,
25
24
  DeployedDagDetailEndpoint,
26
25
  )
27
-
26
+ from .data_check import (
27
+ DataCheckExecutionEndpoint,
28
+ DataCheckInstanceEndpoint,
29
+ DataCheckCaseEndpoint,
30
+ )
31
+ from .example_data import ExampleDataListEndpoint, ExampleDataDetailEndpoint
28
32
  from .execution import (
29
33
  ExecutionEndpoint,
30
34
  ExecutionDetailsEndpoint,
@@ -34,35 +38,24 @@ from .execution import (
34
38
  ExecutionRelaunchEndpoint,
35
39
  )
36
40
 
41
+ from .reports import ReportEndpoint, ReportDetailsEndpoint, ReportDetailsEditEndpoint
37
42
  from .health import HealthEndpoint
38
-
39
43
  from .instance import (
40
44
  InstanceEndpoint,
41
45
  InstanceDetailsEndpoint,
42
46
  InstanceFileEndpoint,
43
47
  InstanceDataEndpoint,
44
48
  )
45
-
46
- from .data_check import (
47
- DataCheckExecutionEndpoint,
48
- DataCheckInstanceEndpoint,
49
- DataCheckCaseEndpoint,
50
- )
51
49
  from .licenses import LicensesEndpoint
50
+ from .main_alarms import MainAlarmsEndpoint
52
51
  from .permission import PermissionsViewRoleEndpoint, PermissionsViewRoleDetailEndpoint
53
-
52
+ from .reports import ReportEndpoint, ReportDetailsEndpoint
54
53
  from .roles import RolesListEndpoint, RoleDetailEndpoint
55
-
56
54
  from .schemas import SchemaDetailsEndpoint, SchemaEndpoint
55
+ from .tables import TablesEndpoint, TablesDetailsEndpoint
57
56
  from .token import TokenEndpoint
58
- from .example_data import ExampleDataDetailsEndpoint
59
57
  from .user import UserEndpoint, UserDetailsEndpoint, ToggleUserAdmin, RecoverPassword
60
58
  from .user_role import UserRoleListEndpoint, UserRoleDetailEndpoint
61
- from .alarms import AlarmsEndpoint
62
- from .main_alarms import MainAlarmsEndpoint
63
-
64
- from .tables import TablesEndpoint, TablesDetailsEndpoint
65
-
66
59
 
67
60
  resources = [
68
61
  dict(resource=InstanceEndpoint, urls="/instance/", endpoint="instance"),
@@ -157,10 +150,15 @@ resources = [
157
150
  endpoint="schema-details",
158
151
  ),
159
152
  dict(
160
- resource=ExampleDataDetailsEndpoint,
153
+ resource=ExampleDataListEndpoint,
161
154
  urls="/example/<string:dag_name>/",
162
155
  endpoint="example-data",
163
156
  ),
157
+ dict(
158
+ resource=ExampleDataDetailEndpoint,
159
+ urls="/example/<string:dag_name>/<string:example_name>/",
160
+ endpoint="example-data-detail",
161
+ ),
164
162
  dict(resource=HealthEndpoint, urls="/health/", endpoint="health"),
165
163
  dict(
166
164
  resource=CaseFromInstanceExecutionEndpoint,
@@ -221,6 +219,17 @@ resources = [
221
219
  urls="/table/<string:table_name>/<string:idx>/",
222
220
  endpoint="tables-detail",
223
221
  ),
222
+ dict(
223
+ resource=ReportDetailsEndpoint,
224
+ urls="/report/<int:idx>/",
225
+ endpoint="report-detail",
226
+ ),
227
+ dict(
228
+ resource=ReportDetailsEditEndpoint,
229
+ urls="/report/<int:idx>/edit/",
230
+ endpoint="report-detail-edit",
231
+ ),
232
+ dict(resource=ReportEndpoint, urls="/report/", endpoint="report"),
224
233
  ]
225
234
 
226
235
 
@@ -1,36 +1,78 @@
1
1
  """
2
2
  Endpoints to get the example data from a DAG
3
3
  """
4
+ import json
4
5
 
5
- # Import from libraries
6
6
  from cornflow_client.airflow.api import Airflow
7
7
  from flask import current_app, request
8
8
  from flask_apispec import marshal_with, doc
9
- import json
10
9
 
11
- # Import from internal modules
12
10
  from cornflow.endpoints.meta_resource import BaseMetaResource
13
11
  from cornflow.models import PermissionsDAG
14
- from cornflow.schemas.example_data import ExampleData
12
+ from cornflow.schemas.example_data import ExampleListData, ExampleDetailData
15
13
  from cornflow.shared.authentication import Auth, authenticate
16
14
  from cornflow.shared.const import VIEWER_ROLE, PLANNER_ROLE, ADMIN_ROLE
17
- from cornflow.shared.exceptions import AirflowError, NoPermission
15
+ from cornflow.shared.exceptions import AirflowError, NoPermission, ObjectDoesNotExist
18
16
 
19
17
 
20
- class ExampleDataDetailsEndpoint(BaseMetaResource):
18
+ class ExampleDataListEndpoint(BaseMetaResource):
21
19
  """
22
20
  Endpoint used to obtain schemas for one app
23
21
  """
24
22
 
25
23
  ROLES_WITH_ACCESS = [VIEWER_ROLE, PLANNER_ROLE, ADMIN_ROLE]
26
24
 
27
- @doc(description="Get example data from DAG", tags=["DAG"])
25
+ @doc(description="Get lsit of example data from DAG", tags=["DAG"])
28
26
  @authenticate(auth_class=Auth())
29
- @marshal_with(ExampleData)
27
+ @marshal_with(ExampleListData(many=True))
30
28
  def get(self, dag_name):
31
29
  """
32
30
  API method to get example data for a given dag
33
31
 
32
+ :return: A dictionary with the names and descriptions of available data examples
33
+ and an integer with the HTTP status code
34
+ :rtype: Tuple(dict, integer)
35
+ """
36
+ user = Auth().get_user_from_header(request.headers)
37
+ permission = PermissionsDAG.check_if_has_permissions(
38
+ user_id=user.id, dag_id=dag_name
39
+ )
40
+
41
+ if permission:
42
+ af_client = Airflow.from_config(current_app.config)
43
+ if not af_client.is_alive():
44
+ current_app.logger.error(
45
+ "Airflow not accessible when getting data {}".format(dag_name)
46
+ )
47
+ raise AirflowError(error="Airflow is not accessible")
48
+
49
+ # try airflow and see if dag_name exists
50
+ af_client.get_dag_info(dag_name)
51
+
52
+ current_app.logger.info("User gets example data from {}".format(dag_name))
53
+
54
+ variable_name = f"z_{dag_name}_examples"
55
+ response = af_client.get_one_variable(variable_name)
56
+
57
+ return json.loads(response["value"])
58
+ else:
59
+ err = "User does not have permission to access this dag."
60
+ raise NoPermission(
61
+ error=err,
62
+ status_code=403,
63
+ log_txt=f"Error while user {user} tries to get example data for dag {dag_name}. "
64
+ + err,
65
+ )
66
+
67
+
68
+ class ExampleDataDetailEndpoint(BaseMetaResource):
69
+ @doc(description="Get example data from DAG", tags=["DAG"])
70
+ @authenticate(auth_class=Auth())
71
+ @marshal_with(ExampleDetailData)
72
+ def get(self, dag_name, example_name):
73
+ """
74
+ API method to get one example data for a given dag
75
+
34
76
  :return: A dictionary with a message and a integer with the HTTP status code
35
77
  :rtype: Tuple(dict, integer)
36
78
  """
@@ -54,15 +96,24 @@ class ExampleDataDetailsEndpoint(BaseMetaResource):
54
96
 
55
97
  variable_name = f"z_{dag_name}_examples"
56
98
  response = af_client.get_one_variable(variable_name)
57
- result = dict()
58
- result["examples"] = json.loads(response["value"])
59
- result["name"] = response["key"]
60
99
 
61
- return result
100
+ example = None
101
+ for item in json.loads(response["value"]):
102
+ if item["name"] == example_name:
103
+ example = item
104
+ break
105
+
106
+ if example is None:
107
+ raise ObjectDoesNotExist(
108
+ error="The example does not exist", status_code=404
109
+ )
110
+
111
+ return example
62
112
  else:
63
113
  err = "User does not have permission to access this dag."
64
114
  raise NoPermission(
65
115
  error=err,
66
116
  status_code=403,
67
- log_txt=f"Error while user {user} tries to get example data for dag {dag_name}. " + err
117
+ log_txt=f"Error while user {user} tries to get example data for dag {dag_name}. "
118
+ + err,
68
119
  )
@@ -1,7 +1,7 @@
1
1
  """
2
2
  External endpoints to manage the executions: create new ones, list all of them, get one in particular
3
3
  or check the status of an ongoing one
4
- These endpoints hve different access url, but manage the same data entities
4
+ These endpoints have different access url, but manage the same data entities
5
5
  """
6
6
 
7
7
  # Import from libraries
@@ -34,6 +34,7 @@ class LoginBaseEndpoint(BaseMetaResource):
34
34
  """
35
35
  Base endpoint to perform a login action from a user
36
36
  """
37
+
37
38
  def __init__(self):
38
39
  super().__init__()
39
40
  self.ldap_class = LDAPBase
@@ -63,6 +64,7 @@ class LoginBaseEndpoint(BaseMetaResource):
63
64
  try:
64
65
  token = self.auth_class.generate_token(user.id)
65
66
  except Exception as e:
67
+ current_app.logger.error(f"Error in generating user token: {str(e)}")
66
68
  raise InvalidUsage(f"Error in generating user token: {str(e)}", 400)
67
69
 
68
70
  response.update({"token": token, "id": user.id})
@@ -81,9 +83,11 @@ class LoginBaseEndpoint(BaseMetaResource):
81
83
  user = self.data_model.get_one_object(username=username)
82
84
 
83
85
  if not user:
86
+ current_app.logger.error(f"Error on login user does not exist")
84
87
  raise InvalidCredentials()
85
88
 
86
89
  if not user.check_hash(password):
90
+ current_app.logger.error(f"Error on login invalid credentials")
87
91
  raise InvalidCredentials()
88
92
 
89
93
  return user
@@ -102,7 +106,9 @@ class LoginBaseEndpoint(BaseMetaResource):
102
106
  raise InvalidCredentials()
103
107
  user = self.data_model.get_one_object(username=username)
104
108
  if not user:
105
- current_app.logger.info(f"LDAP user {username} does not exist and is created")
109
+ current_app.logger.info(
110
+ f"LDAP user {username} does not exist and is created"
111
+ )
106
112
  email = ldap_obj.get_user_email(username)
107
113
  if not email:
108
114
  email = ""
@@ -122,10 +128,14 @@ class LoginBaseEndpoint(BaseMetaResource):
122
128
 
123
129
  except IntegrityError as e:
124
130
  db.session.rollback()
125
- current_app.logger.error(f"Integrity error on user role assignment on log in: {e}")
131
+ current_app.logger.error(
132
+ f"Integrity error on user role assignment on log in: {e}"
133
+ )
126
134
  except DBAPIError as e:
127
135
  db.session.rollback()
128
- current_app.logger.error(f"Unknown error on user role assignment on log in: {e}")
136
+ current_app.logger.error(
137
+ f"Unknown error on user role assignment on log in: {e}"
138
+ )
129
139
 
130
140
  return user
131
141
 
@@ -163,7 +173,9 @@ class LoginBaseEndpoint(BaseMetaResource):
163
173
  user = self.data_model.get_one_object(username=username)
164
174
 
165
175
  if not user:
166
- current_app.logger.info(f"OpenID user {username} does not exist and is created")
176
+ current_app.logger.info(
177
+ f"OpenID user {username} does not exist and is created"
178
+ )
167
179
 
168
180
  data = {"username": username, "email": username}
169
181
 
@@ -183,7 +195,11 @@ class LoginBaseEndpoint(BaseMetaResource):
183
195
 
184
196
  def check_last_password_change(user):
185
197
  if user.pwd_last_change:
186
- if user.pwd_last_change + timedelta(days=int(current_app.config["PWD_ROTATION_TIME"])) < datetime.utcnow():
198
+ if (
199
+ user.pwd_last_change
200
+ + timedelta(days=int(current_app.config["PWD_ROTATION_TIME"]))
201
+ < datetime.utcnow()
202
+ ):
187
203
  return True
188
204
  return False
189
205
 
@@ -210,7 +226,11 @@ class LoginEndpoint(LoginBaseEndpoint):
210
226
  :rtype: Tuple(dict, integer)
211
227
  """
212
228
 
213
- return self.log_in(**kwargs)
229
+ try:
230
+ return self.log_in(**kwargs)
231
+ except Exception as e:
232
+ current_app.logger.error(f"Final exception: {str(e)}")
233
+ raise e
214
234
 
215
235
 
216
236
  class LoginOpenAuthEndpoint(LoginBaseEndpoint):
@@ -0,0 +1,283 @@
1
+ """
2
+ External endpoints to manage the reports: create new ones, list all of them, get one in particular
3
+ These endpoints have different access url, but manage the same data entities
4
+ """
5
+ import os
6
+
7
+ from flask import current_app, request, send_from_directory
8
+ from flask_apispec import marshal_with, use_kwargs, doc
9
+ from werkzeug.utils import secure_filename
10
+ import uuid
11
+
12
+ from cornflow.endpoints.meta_resource import BaseMetaResource
13
+ from cornflow.models import ExecutionModel, ReportModel
14
+ from cornflow.schemas.reports import (
15
+ ReportSchema,
16
+ ReportEditRequest,
17
+ QueryFiltersReports,
18
+ ReportRequest,
19
+ )
20
+ from cornflow.shared.authentication import Auth, authenticate
21
+ from cornflow.shared.const import SERVICE_ROLE
22
+ from cornflow.shared.exceptions import (
23
+ FileError,
24
+ ObjectDoesNotExist,
25
+ NoPermission,
26
+ )
27
+
28
+
29
+ class ReportEndpoint(BaseMetaResource):
30
+ """
31
+ Endpoint used to create a new report or get all the reports and their information back
32
+ """
33
+
34
+ ROLES_WITH_ACCESS = [SERVICE_ROLE]
35
+
36
+ def __init__(self):
37
+ super().__init__()
38
+ self.model = ReportModel
39
+ self.data_model = ReportModel
40
+ self.foreign_data = {"execution_id": ExecutionModel}
41
+
42
+ @doc(description="Get all reports", tags=["Reports"])
43
+ @authenticate(auth_class=Auth())
44
+ @marshal_with(ReportSchema(many=True))
45
+ @use_kwargs(QueryFiltersReports, location="query")
46
+ def get(self, **kwargs):
47
+ """
48
+ API method to get all the reports created by the user and its related info
49
+ It requires authentication to be passed in the form of a token that has to be linked to
50
+ an existing session (login) made by a user
51
+
52
+ :return: A dictionary with a message (error if authentication failed or a list with all the reports
53
+ created by the authenticated user) and a integer with the HTTP status code
54
+ :rtype: Tuple(dict, integer)
55
+ """
56
+ reports = self.get_list(user=self.get_user(), **kwargs)
57
+ current_app.logger.info(f"User {self.get_user()} gets list of reports")
58
+ return reports
59
+
60
+ @doc(description="Create a report", tags=["Reports"])
61
+ @authenticate(auth_class=Auth())
62
+ @use_kwargs(ReportRequest, location="form")
63
+ @marshal_with(ReportSchema)
64
+ def post(self, **kwargs):
65
+ """
66
+ API method to create a new report linked to an existing execution
67
+ It requires authentication to be passed in the form of a token that has to be linked to
68
+ an existing session (login) made by a user
69
+
70
+ :return: A dictionary with a message (error if authentication failed, error if data is not validated or
71
+ the reference_id for the newly created report if successful) and a integer with the HTTP status code
72
+ :rtype: Tuple(dict, integer)
73
+ """
74
+
75
+ execution = ExecutionModel.get_one_object(idx=kwargs["execution_id"])
76
+
77
+ if execution is None:
78
+ raise ObjectDoesNotExist("The execution does not exist")
79
+ if "file" not in request.files:
80
+ # we're creating an empty report.
81
+ # which is possible
82
+ report = ReportModel(get_report_info(kwargs, execution, None))
83
+
84
+ report.save()
85
+ return report, 201
86
+
87
+ file = request.files["file"]
88
+ report_name = new_file_name(file)
89
+
90
+ report = ReportModel(get_report_info(kwargs, execution, report_name))
91
+
92
+ report.save()
93
+
94
+ # We try to save the file, if an error is raised then we delete the record on the database
95
+ try:
96
+ write_file(file, execution.id, report_name)
97
+ return report, 201
98
+
99
+ except Exception as error:
100
+ report.delete()
101
+ current_app.logger.error(error)
102
+ raise FileError(error=str(error))
103
+
104
+
105
+ class ReportDetailsEndpointBase(BaseMetaResource):
106
+ """
107
+ Endpoint used to get the information of a certain report. But not the data!
108
+ """
109
+
110
+ def __init__(self):
111
+ super().__init__()
112
+ self.data_model = ReportModel
113
+ self.foreign_data = {"execution_id": ExecutionModel}
114
+
115
+
116
+ class ReportDetailsEditEndpoint(ReportDetailsEndpointBase):
117
+
118
+ ROLES_WITH_ACCESS = [SERVICE_ROLE]
119
+
120
+ @doc(description="Edit a report", tags=["Reports"], inherit=False)
121
+ @authenticate(auth_class=Auth())
122
+ @use_kwargs(ReportEditRequest, location="form")
123
+ def put(self, idx, **data):
124
+ """
125
+ Edit an existing report
126
+
127
+ :param string idx: ID of the report.
128
+ :return: A dictionary with a message (error if authentication failed, or the report does not exist or
129
+ a message) and an integer with the HTTP status code.
130
+ :rtype: Tuple(dict, integer)
131
+ """
132
+ # TODO: forbid non-service users from running put
133
+ current_app.logger.info(f"User {self.get_user()} edits report {idx}")
134
+
135
+ report = self.get_detail(idx=idx)
136
+
137
+ if "file" not in request.files:
138
+ # we're creating an empty report.
139
+ # which is possible
140
+ report.update(data)
141
+ report.save()
142
+ return {"message": "Updated correctly"}, 200
143
+
144
+ # there's two cases,
145
+ # (1) the report already has a file
146
+ # (2) the report doesn't yet have a file
147
+ file = request.files["file"]
148
+ report_name = new_file_name(file)
149
+ old_name = report.file_url
150
+ # we update the report with the new content, including the new name
151
+ report.update(dict(**data, file_url=report_name))
152
+
153
+ # We try to save the file, if an error is raised then we delete the record on the database
154
+ try:
155
+ write_file(file, report.execution_id, report_name)
156
+ report.save()
157
+
158
+ except Exception as error:
159
+ # we do not save the report
160
+ current_app.logger.error(error)
161
+ raise FileError(error=str(error))
162
+
163
+ # if it saves correctly, we delete the old file, if exists
164
+ # if unsuccessful, we still return 201 but log the error
165
+ if old_name is not None:
166
+ try:
167
+ os.remove(get_report_path(report))
168
+ except OSError as error:
169
+ current_app.logger.error(error)
170
+ return {"message": "Updated correctly"}, 200
171
+
172
+
173
+ class ReportDetailsEndpoint(ReportDetailsEndpointBase):
174
+ @doc(description="Get details of a report", tags=["Reports"], inherit=False)
175
+ @authenticate(auth_class=Auth())
176
+ @marshal_with(ReportSchema)
177
+ @BaseMetaResource.get_data_or_404
178
+ def get(self, idx):
179
+ """
180
+ API method to get a report created by the user and its related info.
181
+ It requires authentication to be passed in the form of a token that has to be linked to
182
+ an existing session (login) made by a user.
183
+
184
+ :param str idx: ID of the report.
185
+ :return: A dictionary with a message (error if authentication failed, or the report does not exist or
186
+ the data of the report) and an integer with the HTTP status code.
187
+ :rtype: Tuple(dict, integer)
188
+ """
189
+ # TODO: are we able to download the name in the database and not as part of the file?
190
+ current_app.logger.info(f"User {self.get_user()} gets details of report {idx}")
191
+ report = self.get_detail(user=self.get_user(), idx=idx)
192
+
193
+ if report is None:
194
+ raise ObjectDoesNotExist
195
+
196
+ # if there's no file, we do not return it:
197
+ if report.file_url is None:
198
+ return report, 200
199
+
200
+ my_dir = get_report_dir(report.execution_id)
201
+ response = send_from_directory(my_dir, report.file_url)
202
+ response.headers["File-Description"] = report.description
203
+ response.headers["File-Name"] = report.file_url
204
+ return response
205
+
206
+ @doc(description="Delete a report", tags=["Reports"], inherit=False)
207
+ @authenticate(auth_class=Auth())
208
+ def delete(self, idx):
209
+ """
210
+ API method to delete a report created by the user and its related info.
211
+ It requires authentication to be passed in the form of a token that has to be linked to
212
+ an existing session (login) made by a user.
213
+
214
+ :param string idx: ID of the report.
215
+ :return: A dictionary with a message (error if authentication failed, or the report does not exist or
216
+ a message) and an integer with the HTTP status code.
217
+ :rtype: Tuple(dict, integer)
218
+ """
219
+
220
+ # get report objet
221
+ report = self.get_detail(user_id=self.get_user_id(), idx=idx)
222
+
223
+ if report is None:
224
+ raise ObjectDoesNotExist
225
+
226
+ # delete file
227
+ os.remove(get_report_path(report))
228
+
229
+ return self.delete_detail(user_id=self.get_user_id(), idx=idx)
230
+
231
+
232
+ def get_report_dir(execution_id):
233
+ return f"{current_app.config['UPLOAD_FOLDER']}/{execution_id}"
234
+
235
+
236
+ def get_report_path(report):
237
+ try:
238
+ return f"{get_report_dir(report['execution_id'])}/{report['file_url']}"
239
+ except:
240
+ return f"{get_report_dir(report.execution_id)}/{report.file_url}"
241
+
242
+
243
+ def new_file_name(file):
244
+
245
+ filename = secure_filename(file.filename)
246
+ filename_extension = filename.split(".")[-1]
247
+
248
+ if filename_extension not in current_app.config["ALLOWED_EXTENSIONS"]:
249
+ return {
250
+ "message": f"Invalid file extension. "
251
+ f"Valid extensions are: {current_app.config['ALLOWED_EXTENSIONS']}"
252
+ }, 400
253
+
254
+ report_name = f"{uuid.uuid4().hex}.{filename_extension}"
255
+
256
+ return report_name
257
+
258
+
259
+ def write_file(file, execution_id, file_name):
260
+ my_directory = get_report_dir(execution_id)
261
+
262
+ # we create a directory for the execution
263
+ if not os.path.exists(my_directory):
264
+ current_app.logger.info(f"Creating directory {my_directory}")
265
+ os.mkdir(my_directory)
266
+
267
+ save_path = os.path.normpath(os.path.join(my_directory, file_name))
268
+
269
+ if "static" not in save_path or ".." in save_path:
270
+ raise NoPermission("Invalid file name")
271
+ file.save(save_path)
272
+
273
+
274
+ def get_report_info(data, execution, file_url=None):
275
+ return {
276
+ "name": data["name"],
277
+ "file_url": file_url,
278
+ "execution_id": execution.id,
279
+ "user_id": execution.user_id,
280
+ "description": data.get("description", ""),
281
+ "state": data.get("state"),
282
+ "state_message": data.get("state_message"),
283
+ }