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
@@ -0,0 +1,40 @@
1
+ """empty message
2
+
3
+ Revision ID: 83164be03c23
4
+ Revises: 96f00d0961d1
5
+ Create Date: 2024-07-23 13:18:47.748324
6
+
7
+ """
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = '83164be03c23'
14
+ down_revision = '96f00d0961d1'
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade():
20
+ # ### commands auto generated by Alembic - please adjust! ###
21
+ with op.batch_alter_table('reports', schema=None) as batch_op:
22
+ batch_op.add_column(sa.Column('state', sa.SmallInteger(), nullable=True))
23
+ batch_op.add_column(sa.Column('state_message', sa.TEXT(), nullable=True))
24
+ batch_op.alter_column('file_url',
25
+ existing_type=sa.VARCHAR(length=256),
26
+ nullable=True)
27
+
28
+ # ### end Alembic commands ###
29
+
30
+
31
+ def downgrade():
32
+ # ### commands auto generated by Alembic - please adjust! ###
33
+ with op.batch_alter_table('reports', schema=None) as batch_op:
34
+ batch_op.alter_column('file_url',
35
+ existing_type=sa.VARCHAR(length=256),
36
+ nullable=False)
37
+ batch_op.drop_column('state_message')
38
+ batch_op.drop_column('state')
39
+
40
+ # ### end Alembic commands ###
@@ -0,0 +1,50 @@
1
+ """
2
+ Adds reports table to database
3
+
4
+ Revision ID: 96f00d0961d1
5
+ Revises: 991b98e24225
6
+ Create Date: 2024-06-12 18:47:06.366487
7
+
8
+ """
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision = "96f00d0961d1"
16
+ down_revision = "991b98e24225"
17
+ branch_labels = None
18
+ depends_on = None
19
+
20
+
21
+ def upgrade():
22
+ # ### commands auto generated by Alembic - please adjust! ###
23
+ op.create_table(
24
+ "reports",
25
+ sa.Column("created_at", sa.DateTime(), nullable=False),
26
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
27
+ sa.Column("deleted_at", sa.DateTime(), nullable=True),
28
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
29
+ sa.Column("execution_id", sa.String(length=256), nullable=False),
30
+ sa.Column("name", sa.String(length=256), nullable=False),
31
+ sa.Column("description", sa.TEXT(), nullable=True),
32
+ sa.Column("file_url", sa.String(length=256), nullable=False),
33
+ sa.Column("user_id", sa.Integer(), nullable=False),
34
+ sa.ForeignKeyConstraint(
35
+ ["execution_id"],
36
+ ["executions.id"],
37
+ ),
38
+ sa.ForeignKeyConstraint(
39
+ ["user_id"],
40
+ ["users.id"],
41
+ ),
42
+ sa.PrimaryKeyConstraint("id"),
43
+ )
44
+ # ### end Alembic commands ###
45
+
46
+
47
+ def downgrade():
48
+ # ### commands auto generated by Alembic - please adjust! ###
49
+ op.drop_table("reports")
50
+ # ### end Alembic commands ###
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Initialization file for the models module
3
3
  """
4
+
4
5
  from .action import ActionModel
5
6
  from .alarms import AlarmsModel
6
7
  from .case import CaseModel
@@ -14,3 +15,4 @@ from .role import RoleModel
14
15
  from .user import UserModel
15
16
  from .user_role import UserRoleModel
16
17
  from .view import ViewModel
18
+ from .reports import ReportModel
@@ -64,6 +64,14 @@ class ExecutionModel(BaseDataModel):
64
64
  default=EXECUTION_STATE_MESSAGE_DICT[DEFAULT_EXECUTION_CODE],
65
65
  nullable=True,
66
66
  )
67
+ reports = db.relationship(
68
+ "ReportModel",
69
+ backref="executions",
70
+ lazy=True,
71
+ primaryjoin="and_(ExecutionModel.id==ReportModel.execution_id, "
72
+ "ReportModel.deleted_at==None)",
73
+ cascade="all,delete",
74
+ )
67
75
 
68
76
  def __init__(self, data):
69
77
  super().__init__(data)
@@ -33,17 +33,29 @@ class EmptyBaseModel(db.Model):
33
33
 
34
34
  try:
35
35
  db.session.commit()
36
- current_app.logger.debug(f"Transaction type: {action}, performed correctly on {self}")
36
+ current_app.logger.debug(
37
+ f"Transaction type: {action}, performed correctly on {self}"
38
+ )
39
+
37
40
  except IntegrityError as err:
38
41
  db.session.rollback()
39
42
  current_app.logger.error(f"Integrity error on {action} data: {err}")
40
43
  current_app.logger.error(f"Data: {self.__dict__}")
41
- raise InvalidData(f"Integrity error on {action} with data {self}")
44
+
45
+ if "FOREIGN KEY" in str(err):
46
+ message = f"Foreign key constraint error while {action} on {self.__class__.__tablename__} table"
47
+ raise InvalidData(message)
48
+ else:
49
+ raise InvalidData(f"Integrity error on {action} with data {self}")
50
+
42
51
  except DBAPIError as err:
43
52
  db.session.rollback()
44
- current_app.logger.error(f"Unknown database error on {action} data: {err}")
53
+ current_app.logger.error(
54
+ f"Unknown database error on {action} data: {type(err)}"
55
+ )
45
56
  current_app.logger.error(f"Data: {self.__dict__}")
46
57
  raise InvalidData(f"Unknown database error on {action} with data {self}")
58
+
47
59
  except Exception as err:
48
60
  db.session.rollback()
49
61
  current_app.logger.error(f"Unknown error on {action} data: {err}")
@@ -99,7 +111,9 @@ class EmptyBaseModel(db.Model):
99
111
  action = "bulk create"
100
112
  try:
101
113
  db.session.commit()
102
- current_app.logger.debug(f"Transaction type: {action}, performed correctly on {cls}")
114
+ current_app.logger.debug(
115
+ f"Transaction type: {action}, performed correctly on {cls}"
116
+ )
103
117
  except IntegrityError as err:
104
118
  db.session.rollback()
105
119
  current_app.logger.error(f"Integrity error on {action} data: {err}")
@@ -120,7 +134,9 @@ class EmptyBaseModel(db.Model):
120
134
  action = "bulk create update"
121
135
  try:
122
136
  db.session.commit()
123
- current_app.logger.debug(f"Transaction type: {action}, performed correctly on {cls}")
137
+ current_app.logger.debug(
138
+ f"Transaction type: {action}, performed correctly on {cls}"
139
+ )
124
140
  except IntegrityError as err:
125
141
  db.session.rollback()
126
142
  current_app.logger.error(f"Integrity error on {action} data: {err}")
@@ -136,12 +152,7 @@ class EmptyBaseModel(db.Model):
136
152
  return instances
137
153
 
138
154
  @classmethod
139
- def get_all_objects(
140
- cls,
141
- offset=0,
142
- limit=None,
143
- **kwargs
144
- ):
155
+ def get_all_objects(cls, offset=0, limit=None, **kwargs):
145
156
  """
146
157
  Method to get all the objects from the database applying the filters passed as keyword arguments
147
158
 
@@ -261,7 +272,7 @@ class TraceAttributesModel(EmptyBaseModel):
261
272
  update_date_lte=None,
262
273
  offset=0,
263
274
  limit=None,
264
- **kwargs
275
+ **kwargs,
265
276
  ):
266
277
  """
267
278
  Method to get all the objects from the database applying the filters passed as keyword arguments
@@ -0,0 +1,119 @@
1
+ """
2
+ Model for the reports
3
+ """
4
+
5
+ # Import from libraries
6
+ from sqlalchemy.dialects.postgresql import TEXT
7
+ from sqlalchemy.ext.declarative import declared_attr
8
+
9
+ # Imports from internal modules
10
+ from cornflow.models.base_data_model import TraceAttributesModel
11
+ from cornflow.shared import db
12
+ from cornflow.shared.const import REPORT_STATE, REPORT_STATE_MSG
13
+
14
+
15
+ class ReportModel(TraceAttributesModel):
16
+ """
17
+ Model class for the Reports.
18
+ It inherits from :class:`TraceAttributesModel<cornflow.models.base_data_model.TraceAttributesModel>` to have the trace fields and user field.
19
+
20
+ - **id**: int, the report id, primary key for the reports.
21
+ - **execution_id**: str, the foreign key for the execution (:class:`ExecutionModel`). It links the report to its
22
+ parent execution.
23
+ - **file_url**: str, the link with the actual report. It should be a valid url to a cloud storage bucket or a file.
24
+ - **name**: str, the name of the report given by the user.
25
+ - **description**: str, the description of the report given by the user. It is optional.
26
+ - **user_id**: int, the foreign key for the user (:class:`UserModel`). It links the report to its owner.
27
+ - **created_at**: datetime, the datetime when the report was created (in UTC).
28
+ This datetime is generated automatically, the user does not need to provide it.
29
+ - **updated_at**: datetime, the datetime when the report was last updated (in UTC).
30
+ This datetime is generated automatically, the user does not need to provide it.
31
+ - **deleted_at**: datetime, the datetime when the report was deleted (in UTC). Even though it is deleted,
32
+ actually, it is not deleted from the database, in order to have a command that cleans up deleted data
33
+ after a certain time of its deletion.
34
+ This datetime is generated automatically, the user does not need to provide it.
35
+
36
+ """
37
+
38
+ # Table name in the database
39
+ __tablename__ = "reports"
40
+
41
+ # Model fields
42
+ id = db.Column(db.Integer, primary_key=True, autoincrement=True)
43
+ execution_id = db.Column(
44
+ db.String(256), db.ForeignKey("executions.id"), nullable=False
45
+ )
46
+ name = db.Column(db.String(256), nullable=False)
47
+ description = db.Column(TEXT, nullable=True)
48
+ file_url = db.Column(db.String(256), nullable=True)
49
+ state = db.Column(db.SmallInteger, default=REPORT_STATE.CORRECT)
50
+ state_message = db.Column(TEXT, default=REPORT_STATE_MSG[REPORT_STATE.CORRECT])
51
+
52
+ @declared_attr
53
+ def user_id(self):
54
+ """
55
+ The foreign key for the user (:class:`UserModel<cornflow.models.UserModel>`).
56
+ """
57
+ return db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
58
+
59
+ @declared_attr
60
+ def user(self):
61
+ return db.relationship("UserModel")
62
+
63
+ def __init__(self, data: dict):
64
+ super().__init__()
65
+ self.user_id = data.get("user_id")
66
+ self.execution_id = data.get("execution_id")
67
+ self.name = data.get("name")
68
+ self.description = data.get("description")
69
+ self.file_url = data.get("file_url")
70
+ self.state = data.get("state")
71
+ if self.state is None:
72
+ if self.file_url is None:
73
+ self.state = REPORT_STATE.UNKNOWN
74
+ else:
75
+ self.state = REPORT_STATE.CORRECT
76
+ self.state_message = data.get("state_message")
77
+ if self.state_message is None:
78
+ self.state_message = REPORT_STATE_MSG.get(self.state)
79
+
80
+ def update(self, data):
81
+ """
82
+ Method used to update a report from the database
83
+
84
+ :param dict data: the data of the object
85
+ :return: None
86
+ :rtype: None
87
+ """
88
+ # we try to keep the state_message synced, by default
89
+ if "state" in data and "state_message" not in data:
90
+ data["state_message"] = REPORT_STATE_MSG[data["state"]]
91
+ super().update(data)
92
+
93
+ def update_link(self, file_url: str):
94
+ """
95
+ Method to update the report link
96
+
97
+ :param str file_url: new URL for the report
98
+ :return: nothing
99
+ """
100
+ self.file_url = file_url
101
+ super().update({})
102
+
103
+ def __repr__(self):
104
+ """
105
+ Method to represent the class :class:`ReportModel`
106
+
107
+ :return: The representation of the :class:`ReportModel`
108
+ :rtype: str
109
+ """
110
+ return f"<Report {self.id}>"
111
+
112
+ def __str__(self):
113
+ """
114
+ Method to print a string representation of the :class:`ReportModel`
115
+
116
+ :return: The string for the :class:`ReportModel`
117
+ :rtype: str
118
+ """
119
+ return self.__repr__()
@@ -1,6 +1,11 @@
1
1
  from marshmallow import fields, Schema
2
2
 
3
3
 
4
- class ExampleData(Schema):
4
+ class ExampleListData(Schema):
5
5
  name = fields.Str(required=True)
6
- examples = fields.Raw(required=True)
6
+ description = fields.Str(required=False)
7
+
8
+
9
+ class ExampleDetailData(ExampleListData):
10
+ instance = fields.Raw(required=True)
11
+ solution = fields.Raw(required=False)
@@ -5,6 +5,7 @@ from marshmallow import fields, Schema, validate
5
5
  from cornflow.shared.const import MIN_EXECUTION_STATUS_CODE, MAX_EXECUTION_STATUS_CODE
6
6
  from .common import QueryFilters, BaseDataEndpointResponse
7
7
  from .solution_log import LogSchema, BasicLogSchema
8
+ from .reports import ReportSchemaBase
8
9
 
9
10
 
10
11
  class QueryFiltersExecution(QueryFilters):
@@ -30,6 +31,7 @@ class ConfigSchema(Schema):
30
31
  threads = fields.Int(required=False)
31
32
  logPath = fields.Str(required=False)
32
33
  MIPGap = fields.Float(required=False)
34
+ report = fields.Raw(required=False)
33
35
 
34
36
 
35
37
  class ConfigSchemaResponse(ConfigSchema):
@@ -95,6 +97,7 @@ class ExecutionDagPostRequest(ExecutionRequest, ExecutionDagRequest):
95
97
 
96
98
 
97
99
  class ExecutionDetailsEndpointResponse(BaseDataEndpointResponse):
100
+ reports = fields.Nested(ReportSchemaBase, many=True)
98
101
  config = fields.Nested(ConfigSchemaResponse)
99
102
  instance_id = fields.Str()
100
103
  state = fields.Int()
@@ -0,0 +1,48 @@
1
+ # Imports from libraries
2
+ from marshmallow import fields, Schema, INCLUDE
3
+
4
+ # Imports from internal modules
5
+ from .common import BaseQueryFilters
6
+
7
+
8
+ class QueryFiltersReports(BaseQueryFilters):
9
+ execution_id = fields.Str(required=False)
10
+
11
+
12
+ class ReportSchemaBase(Schema):
13
+ id = fields.Int(dump_only=True)
14
+ file_url = fields.Str(required=False)
15
+ name = fields.Str(required=True)
16
+ state = fields.Int()
17
+
18
+
19
+ class ReportSchema(ReportSchemaBase):
20
+ user_id = fields.Int(required=False, load_only=True)
21
+ execution_id = fields.Str(required=True)
22
+ description = fields.Str()
23
+ state_message = fields.Str()
24
+ created_at = fields.DateTime(dump_only=True)
25
+ updated_at = fields.DateTime(dump_only=True)
26
+ deleted_at = fields.DateTime(dump_only=True)
27
+
28
+
29
+ class ReportEditRequest(Schema):
30
+ class META:
31
+ unknown = INCLUDE
32
+
33
+ name = fields.Str()
34
+ description = fields.Str()
35
+ file_url = fields.Str(required=False)
36
+ state = fields.Int()
37
+ state_message = fields.Str()
38
+
39
+
40
+ class ReportRequest(Schema):
41
+ class META:
42
+ unknown = INCLUDE
43
+
44
+ name = fields.Str(required=True)
45
+ description = fields.Str(required=False)
46
+ execution_id = fields.Str(required=True)
47
+ state = fields.Int()
48
+ state_message = fields.Str()
cornflow/shared/const.py CHANGED
@@ -43,6 +43,25 @@ AIRFLOW_TO_STATE_MAP = dict(
43
43
  queued=EXEC_STATE_QUEUED,
44
44
  )
45
45
 
46
+ # Reports codes
47
+
48
+
49
+ class REPORT_STATE:
50
+ RUNNING = 0
51
+ CORRECT = 1
52
+ ERROR = -1
53
+ UNKNOWN = -5
54
+ ERROR_NO_QUARTO = -10
55
+
56
+
57
+ REPORT_STATE_MSG = {
58
+ REPORT_STATE.RUNNING: "The report is currently running.",
59
+ REPORT_STATE.CORRECT: "The report has been solved correctly.",
60
+ REPORT_STATE.ERROR: "The report has an error.",
61
+ REPORT_STATE.UNKNOWN: "The report has an unknown error.",
62
+ REPORT_STATE.ERROR_NO_QUARTO: "The report failed because Quarto was not found.",
63
+ }
64
+
46
65
  # These codes and names are inherited from flask app builder in order to have the same names and values
47
66
  # as this library that is the base of airflow
48
67
  AUTH_DB = 1
@@ -122,4 +141,6 @@ BASE_PERMISSION_ASSIGNATION = [
122
141
 
123
142
  EXTRA_PERMISSION_ASSIGNATION = [
124
143
  (VIEWER_ROLE, PUT_ACTION, "user-detail"),
144
+ (VIEWER_ROLE, GET_ACTION, "report"),
145
+ (PLANNER_ROLE, GET_ACTION, "report"),
125
146
  ]
@@ -21,7 +21,10 @@ class InvalidUsage(Exception):
21
21
  def __init__(self, error=None, status_code=None, payload=None, log_txt=None):
22
22
  Exception.__init__(self, error)
23
23
  if error is not None:
24
- self.error = error
24
+ if isinstance(error, Exception):
25
+ self.error = str(error)
26
+ else:
27
+ self.error = error
25
28
  if status_code is not None:
26
29
  self.status_code = status_code
27
30
  self.payload = payload
@@ -122,10 +125,21 @@ class ConfigurationError(InvalidUsage):
122
125
  error = "No authentication method configured on the server"
123
126
 
124
127
 
128
+ class FileError(InvalidUsage):
129
+ """
130
+ Exception used when there is an error regarding the upload of a file to the server
131
+ """
132
+
133
+ status_code = 400
134
+ error = "Error uploading the file"
135
+
136
+
125
137
  INTERNAL_SERVER_ERROR_MESSAGE = "500 Internal Server Error"
126
- INTERNAL_SERVER_ERROR_MESSAGE_DETAIL = "The server encountered an internal error and was unable " \
127
- "to complete your request. Either the server is overloaded or " \
128
- "there is an error in the application."
138
+ INTERNAL_SERVER_ERROR_MESSAGE_DETAIL = (
139
+ "The server encountered an internal error and was unable "
140
+ "to complete your request. Either the server is overloaded or "
141
+ "there is an error in the application."
142
+ )
129
143
 
130
144
 
131
145
  def initialize_errorhandlers(app):
@@ -187,10 +201,7 @@ def initialize_errorhandlers(app):
187
201
  status_code = error.code or status_code
188
202
  error_msg = f"{status_code} {error.name or INTERNAL_SERVER_ERROR_MESSAGE}"
189
203
  error_str = f"{error_msg}. {str(error.description or '') or INTERNAL_SERVER_ERROR_MESSAGE_DETAIL}"
190
- response_dict = {
191
- "message": error_msg,
192
- "error": error_str
193
- }
204
+ response_dict = {"message": error_msg, "error": error_str}
194
205
  response = jsonify(response_dict)
195
206
 
196
207
  elif app.config["ENV"] == "production":
@@ -202,7 +213,7 @@ def initialize_errorhandlers(app):
202
213
 
203
214
  response_dict = {
204
215
  "message": INTERNAL_SERVER_ERROR_MESSAGE,
205
- "error": INTERNAL_SERVER_ERROR_MESSAGE_DETAIL
216
+ "error": INTERNAL_SERVER_ERROR_MESSAGE_DETAIL,
206
217
  }
207
218
  response = jsonify(response_dict)
208
219
  else: