diff --git a/epictrack-api/migrations/versions/14cebe9c6b1f_work_types.py b/epictrack-api/migrations/versions/14cebe9c6b1f_work_types.py new file mode 100644 index 000000000..4c0045936 --- /dev/null +++ b/epictrack-api/migrations/versions/14cebe9c6b1f_work_types.py @@ -0,0 +1,109 @@ +"""work_types + +Revision ID: 14cebe9c6b1f +Revises: dac396b13921 +Create Date: 2023-11-18 12:57:42.637542 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '14cebe9c6b1f' +down_revision = 'dac396b13921' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute("TRUNCATE works RESTART IDENTITY CASCADE") + op.execute("TRUNCATE work_phases RESTART IDENTITY CASCADE") + op.execute("TRUNCATE event_templates RESTART IDENTITY CASCADE") + op.execute("TRUNCATE event_templates_history RESTART IDENTITY CASCADE") + op.execute("TRUNCATE event_configurations RESTART IDENTITY CASCADE") + op.execute("TRUNCATE event_configurations_history RESTART IDENTITY CASCADE") + op.execute("TRUNCATE work_types RESTART IDENTITY CASCADE") + work_types = [ + { + "name": "Project Notification", + "report_title": "Project Notification", + "is_active": True + }, + { + "name": "Minister''s Designation", + "report_title": "Project Designation Request", + "is_active": True + }, + { + "name": "CEAO''s Designation", + "report_title": "Project Designation Request", + "is_active": True + }, + { + "name": "Intake (Pre-EA)", + "report_title": "Intake (Pre-EA)", + "is_active": False + }, + { + "name": "Exemption Order", + "report_title": "Exemption Order Request", + "is_active": True + }, + { + "name": "Assessment", + "report_title": "EA Certificate Request", + "is_active": True + }, + { + "name": "Amendment", + "report_title": "EAC/Order Amendment", + "is_active": True + }, + { + "name": "Post-EAC Document Review", + "report_title": "Post-Decision Plan Review", + "is_active": False + }, + { + "name": "EAC Extension", + "report_title": "EAC Extension Request", + "is_active": False + }, + { + "name": "Substantial Start Decision", + "report_title": "Substantial Start Decision", + "is_active": False + }, + { + "name": "EAC/Order Transfer", + "report_title": "EAC/Order Transfer", + "is_active": False + }, + { + "name": "EAC/Order Suspension", + "report_title": "EAC/Order Suspension", + "is_active": False + }, + { + "name": "EAC/Order Cancellation", + "report_title": "EAC/Order Cancellation", + "is_active": False + }, + { + "name": "Other", + "report_title": "Other EAO Work", + "is_active": False + } + ] + for index, work_type in enumerate(work_types): + op.execute(f"INSERT INTO work_types(name, report_title, sort_order, is_active)VALUES('{work_type['name']}', '{work_type['report_title']}','{(index + 1) if work_type['name'] != 'Other' else 32767}', {work_type['is_active']})") + ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + + # ### end Alembic commands ### diff --git a/epictrack-api/migrations/versions/213ca6b67dcc_change_in_master_data.py b/epictrack-api/migrations/versions/213ca6b67dcc_change_in_master_data.py new file mode 100644 index 000000000..3e7e5c903 --- /dev/null +++ b/epictrack-api/migrations/versions/213ca6b67dcc_change_in_master_data.py @@ -0,0 +1,501 @@ +"""change in master data + +Revision ID: 213ca6b67dcc +Revises: eacb66ae668b +Create Date: 2023-11-20 22:13:05.217287 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy.sql import column, table, text + +# revision identifiers, used by Alembic. +revision = '213ca6b67dcc' +down_revision = 'eacb66ae668b' +branch_labels = None +depends_on = None + +old_project_state_options = ( + "UNDER_EAC_ASSESSMENT", + "UNDER_EXEMPTION_REQUEST", + "UNDER_AMENDMENT", + "UNDER_DISPUTE_RESOLUTION", + "PRE_CONSTRUCTION", + "CONSTRUCTION", + "OPERATION", + "CARE_AND_MAINTENANCE", + "DECOMMISSION", + "UNKNOWN", +) +new_project_state_options = sorted(old_project_state_options + ("CLOSED", "UNDER_DESIGNATION")) + +old_project_state = sa.Enum(*old_project_state_options, name='projectstateenum') +new_project_state = sa.Enum(*new_project_state_options, name='projectstateenum') +tmp_project_state = sa.Enum(*new_project_state_options, name='_projectstateenum') + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # Create a tempoary "_project_state" type, convert and drop the "old" type + tmp_project_state.create(op.get_bind(), checkfirst=False) + op.execute('ALTER TABLE projects ALTER COLUMN project_state TYPE _projectstateenum' + ' USING project_state::text::_projectstateenum') + op.execute('DROP TYPE projectstateenum CASCADE') + # Create and convert to the "new" project_state type + new_project_state.create(op.get_bind(), checkfirst=False) + op.execute('ALTER TABLE projects ALTER COLUMN project_state TYPE projectstateenum' + ' USING project_state::text::projectstateenum') + tmp_project_state.drop(op.get_bind(), checkfirst=False) + + with op.batch_alter_table("indigenous_nations", schema=None) as batch_op: + batch_op.add_column(sa.Column("bcigid", sa.String(), nullable=True)) + batch_op.alter_column( + "pip_link", + existing_type=sa.TEXT(), + type_=sa.String(), + existing_nullable=True, + ) + + with op.batch_alter_table("indigenous_nations_history", schema=None) as batch_op: + batch_op.add_column( + sa.Column("bcigid", sa.String(), autoincrement=False, nullable=True) + ) + batch_op.alter_column( + "pip_link", + existing_type=sa.TEXT(), + type_=sa.String(), + existing_nullable=True, + autoincrement=False, + ) + + positions_table = table( + "positions", + column("id", sa.Integer), + column("name", sa.String), + column("sort_order", sa.Integer), + ) + types_table = table( + "types", + column("id", sa.Integer), + column("name", sa.String), + column("short_name", sa.String), + column("sort_order", sa.Integer), + ) + sub_types_table = table( + "sub_types", + column("id", sa.Integer), + column("name", sa.String), + column("short_name", sa.String), + column("sort_order", sa.Integer), + column("type_id", sa.Integer), + ) + regions_table = table( + "regions", + column("id", sa.Integer), + column("name", sa.String), + column("entity", sa.String), + column("sort_order", sa.Integer), + ) + + positions = [ + {"name": "Minister", "sort_order": 1}, + {"name": "Associate Deputy Minister", "sort_order": 2}, + {"name": "ADM, EA Operations", "sort_order": 3}, + {"name": "OpsDiv Executive", "sort_order": 4}, + {"name": "Executive Project Director", "sort_order": 5}, + {"name": "Project Assessment Director", "sort_order": 6}, + {"name": "Project Assessment Officer", "sort_order": 7}, + {"name": "Project Analyst", "sort_order": 8}, + {"name": "PA/AA", "sort_order": 9}, + {"name": "IPE", "sort_order": 10}, + {"name": "HR", "sort_order": 11}, + {"name": "Other", "sort_order": 12}, + ] + types = [ + {"short_name": "Energy", "name": "Energy - Electricity", "sort_order": 1}, + { + "short_name": "O&G", + "name": "Energy - Petroleum & Natural Gas", + "sort_order": 2, + }, + {"short_name": "Industry", "name": "Industrial", "sort_order": 3}, + {"short_name": "Mines", "name": "Mines", "sort_order": 4}, + { + "short_name": "Resorts", + "name": "Tourist Destination Resort", + "sort_order": 5, + }, + {"short_name": "Transport", "name": "Transportation", "sort_order": 6}, + {"short_name": "Waste", "name": "Waste Disposal", "sort_order": 7}, + {"short_name": "Water", "name": "Water Management", "sort_order": 8}, + {"short_name": "Other", "name": "Other", "sort_order": 9}, + ] + + sub_types = [ + { + "short_name": "Plant", + "type_id": "Energy - Electricity", + "name": "Power Plants", + "sort_order": 1, + }, + { + "short_name": "Lines", + "type_id": "Energy - Electricity", + "name": "Transmission Lines", + "sort_order": 2, + }, + { + "short_name": "Storage", + "type_id": "Energy - Petroleum & Natural Gas", + "name": "Energy Storage Facilities", + "sort_order": 3, + }, + { + "short_name": "Proccesing", + "type_id": "Energy - Petroleum & Natural Gas", + "name": "Natural Gas Processing Plants", + "sort_order": 4, + }, + { + "short_name": "Offshore", + "type_id": "Energy - Petroleum & Natural Gas", + "name": "Offshore Oil or Gas Facilities", + "sort_order": 5, + }, + { + "short_name": "Refinery", + "type_id": "Energy - Petroleum & Natural Gas", + "name": "Oil Refineries", + "sort_order": 6, + }, + { + "short_name": "Pipeline", + "type_id": "Energy - Petroleum & Natural Gas", + "name": "Transmission Pipelines", + "sort_order": 7, + }, + { + "short_name": "Forest", + "type_id": "Industrial", + "name": "Forest Products", + "sort_order": 8, + }, + { + "short_name": "Non-Metallic", + "type_id": "Industrial", + "name": "Non-metallic Mineral Products", + "sort_order": 9, + }, + { + "short_name": "Chemicals", + "type_id": "Industrial", + "name": "Organic & Inorganic Chemical", + "sort_order": 10, + }, + { + "short_name": "Other", + "type_id": "Industrial", + "name": "Other Industries", + "sort_order": 11, + }, + { + "short_name": "Metals", + "type_id": "Industrial", + "name": "Primary Metals", + "sort_order": 12, + }, + { + "short_name": "Coal", + "type_id": "Mines", + "name": "Coal Mines", + "sort_order": 13, + }, + { + "short_name": "Stone", + "type_id": "Mines", + "name": "Construction Stone & Industrial Mineral Quarries", + "sort_order": 14, + }, + { + "short_name": "Mineral", + "type_id": "Mines", + "name": "Mineral Mines", + "sort_order": 15, + }, + { + "short_name": "Offshore", + "type_id": "Mines", + "name": "Offshore Mines", + "sort_order": 16, + }, + { + "short_name": "Placer", + "type_id": "Mines", + "name": "Placer Mineral Mines", + "sort_order": 17, + }, + { + "short_name": "Gravel", + "type_id": "Mines", + "name": "Sand and Gravel Pits", + "sort_order": 18, + }, + { + "short_name": "Golf", + "type_id": "Tourist Destination Resort", + "name": "Golf Resorts", + "sort_order": 19, + }, + { + "short_name": "Marina", + "type_id": "Tourist Destination Resort", + "name": "Marina Resorts", + "sort_order": 20, + }, + { + "short_name": "Resort", + "type_id": "Tourist Destination Resort", + "name": "Resort Developments", + "sort_order": 21, + }, + { + "short_name": "Ski", + "type_id": "Tourist Destination Resort", + "name": "Ski Resorts", + "sort_order": 22, + }, + { + "short_name": "Airport", + "type_id": "Transportation", + "name": "Airports", + "sort_order": 23, + }, + { + "short_name": "Ferry", + "type_id": "Transportation", + "name": "Ferry Terminals", + "sort_order": 24, + }, + { + "short_name": "Port", + "type_id": "Transportation", + "name": "Marine Ports", + "sort_order": 25, + }, + { + "short_name": "Highway", + "type_id": "Transportation", + "name": "Public Highways", + "sort_order": 26, + }, + { + "short_name": "Railway", + "type_id": "Transportation", + "name": "Railways", + "sort_order": 27, + }, + { + "short_name": "Hazardous", + "type_id": "Waste Disposal", + "name": "Hazardous Waste Facilities", + "sort_order": 28, + }, + { + "short_name": "Liquid", + "type_id": "Waste Disposal", + "name": "Local Government Liquid Waste Management Facilities", + "sort_order": 29, + }, + { + "short_name": "Solid", + "type_id": "Waste Disposal", + "name": "Solid Waste Management Facilities", + "sort_order": 30, + }, + { + "short_name": "Dams", + "type_id": "Water Management", + "name": "Dams", + "sort_order": 31, + }, + { + "short_name": "Dikes", + "type_id": "Water Management", + "name": "Dikes", + "sort_order": 32, + }, + { + "short_name": "Groundwater", + "type_id": "Water Management", + "name": "Groundwater Extraction", + "sort_order": 33, + }, + { + "short_name": "Shoreline", + "type_id": "Water Management", + "name": "Shoreline Modification", + "sort_order": 34, + }, + { + "short_name": "Diversion", + "type_id": "Water Management", + "name": "Water Diversion", + "sort_order": 35, + }, + {"short_name": "Other", "type_id": "Other", "name": "Other", "sort_order": 36}, + ] + env_regions = [ + {"name": "Cariboo", "entity": "ENV", "sort_order": 1}, + {"name": "Kootenay", "entity": "ENV", "sort_order": 2}, + {"name": "Lower Mainland", "entity": "ENV", "sort_order": 3}, + {"name": "Okanagan", "entity": "ENV", "sort_order": 4}, + {"name": "Omineca", "entity": "ENV", "sort_order": 5}, + {"name": "Peace", "entity": "ENV", "sort_order": 6}, + {"name": "Skeena", "entity": "ENV", "sort_order": 7}, + {"name": "Thompson", "entity": "ENV", "sort_order": 8}, + {"name": "Vancouver Island", "entity": "ENV", "sort_order": 9}, + ] + flnro_regions = [ + {"name": "Cariboo", "entity": "FLNR", "sort_order": 1}, + {"name": "Kootenay-Boundary", "entity": "FLNR", "sort_order": 2}, + {"name": "Northeast", "entity": "FLNR", "sort_order": 3}, + {"name": "Omineca", "entity": "FLNR", "sort_order": 4}, + {"name": "Skeena", "entity": "FLNR", "sort_order": 5}, + {"name": "South Coast", "entity": "FLNR", "sort_order": 6}, + {"name": "Thompson-Okanagan", "entity": "FLNR", "sort_order": 7}, + {"name": "West Coast", "entity": "FLNR", "sort_order": 8}, + ] + conn = op.get_bind() + + for position in positions: + position_sort_order = position.pop("sort_order") + conditions = ( + f"WHERE {' AND '.join(map(lambda x: f'{x} = :{x}', position.keys()))} " + ) + query = text(f"SELECT id from positions {conditions}") + position_obj = conn.execute(query, position).fetchone() + + # moving this here because with old data may differ. + position["sort_order"] = position_sort_order + + if position_obj is None: + position_obj = conn.execute( + positions_table.insert() + .values(**position) + .returning((positions_table.c.id).label("position_id")) + ) + else: + conn.execute( + positions_table.update() + .where(positions_table.c.id == position_obj.id) + .values(**position) + ) + for type_data in types: + type_sort_order = type_data.pop("sort_order") + conditions = ( + f"WHERE {' AND '.join(map(lambda x: f'{x} = :{x}', type_data.keys()))} " + ) + query = text(f"SELECT id from types {conditions}") + type_obj = conn.execute(query, type_data).fetchone() + + # moving this here because with old data may differ. + type_data["sort_order"] = type_sort_order + + if type_obj is None: + type_obj = conn.execute( + types_table.insert() + .values(**type_data) + .returning((types_table.c.id).label("type_id")) + ) + else: + conn.execute( + types_table.update() + .where(types_table.c.id == type_obj.id) + .values(**type_data) + ) + for sub_type_data in sub_types: + sub_type_sort_order = sub_type_data.pop("sort_order") + sub_type_data.pop("type_id") + conditions = f"WHERE {' AND '.join(map(lambda x: f'{x} = :{x}', sub_type_data.keys()))} " + query = text(f"SELECT id from sub_types {conditions}") + sub_type_obj = conn.execute(query, sub_type_data).fetchone() + + # moving this here because with old data may differ. + sub_type_data["sort_order"] = sub_type_sort_order + sub_type_data["type_id"] = type_obj.id + + if sub_type_obj is None: + sub_type_obj = conn.execute( + sub_types_table.insert() + .values(**sub_type_data) + .returning((sub_types_table.c.id).label("sub_type_id")) + ) + else: + conn.execute( + sub_types_table.update() + .where(sub_types_table.c.id == sub_type_obj.id) + .values(**sub_type_data) + ) + + regions = env_regions + flnro_regions + for region in regions: + region_sort_order = region.pop("sort_order") + conditions = ( + f"WHERE {' AND '.join(map(lambda x: f'{x} = :{x}', region.keys()))} " + ) + query = text(f"SELECT id from regions {conditions}") + region_obj = conn.execute(query, region).fetchone() + + # moving this here because with old data may differ. + region["sort_order"] = region_sort_order + + if region_obj is None: + region_obj = conn.execute( + regions_table.insert() + .values(**region) + .returning((regions_table.c.id).label("region_id")) + ) + else: + conn.execute( + regions_table.update() + .where(regions_table.c.id == region_obj.id) + .values(**region) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + table = sa.sql.table('projects', + sa.Column('project_state', new_project_state, nullable=True)) + op.execute(table.update().where(table.c.project_state.in_(("CLOSED", "UNDER_DESIGNATION"))) + .values(project_state=None)) + # Create a tempoary "_project_state" type, convert and drop the "new" type + tmp_project_state.create(op.get_bind(), checkfirst=False) + op.execute('ALTER TABLE projects ALTER COLUMN project_state TYPE _projectstateenum' + ' USING project_state::text::_projectstateenum') + op.execute('DROP TYPE projectstateenum CASCADE') + # Create and convert to the "old" project_state type + old_project_state.create(op.get_bind(), checkfirst=False) + op.execute('ALTER TABLE projects ALTER COLUMN project_state TYPE projectstateenum' + ' USING project_state::text::projectstateenum') + tmp_project_state.drop(op.get_bind(), checkfirst=False) + with op.batch_alter_table("indigenous_nations_history", schema=None) as batch_op: + batch_op.alter_column( + "pip_link", + existing_type=sa.String(), + type_=sa.TEXT(), + existing_nullable=True, + autoincrement=False, + ) + batch_op.drop_column("bcigid") + + with op.batch_alter_table("indigenous_nations", schema=None) as batch_op: + batch_op.alter_column( + "pip_link", + existing_type=sa.String(), + type_=sa.TEXT(), + existing_nullable=True, + ) + batch_op.drop_column("bcigid") + # ### end Alembic commands ### diff --git a/epictrack-api/migrations/versions/dac396b13921_template_fields_changes.py b/epictrack-api/migrations/versions/dac396b13921_template_fields_changes.py new file mode 100644 index 000000000..66e88e101 --- /dev/null +++ b/epictrack-api/migrations/versions/dac396b13921_template_fields_changes.py @@ -0,0 +1,112 @@ +"""template fields changes + +Revision ID: dac396b13921 +Revises: 74e3f8a6b3c2 +Create Date: 2023-11-17 12:59:24.873502 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'dac396b13921' +down_revision = '74e3f8a6b3c2' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute("CREATE TYPE eventtemplatevisibilityenum AS ENUM('MANDATORY', 'OPTIONAL', 'HIDDEN')") + op.execute("CREATE TYPE phasevisibilityenum AS ENUM('REGULAR', 'HIDDEN')") + op.execute("TRUNCATE works RESTART IDENTITY CASCADE") + op.execute("TRUNCATE work_phases RESTART IDENTITY CASCADE") + op.execute("TRUNCATE event_templates RESTART IDENTITY CASCADE") + op.execute("TRUNCATE event_templates_history RESTART IDENTITY CASCADE") + op.execute("TRUNCATE event_configurations RESTART IDENTITY CASCADE") + op.execute("TRUNCATE event_configurations_history RESTART IDENTITY CASCADE") + + + with op.batch_alter_table('event_configurations', schema=None) as batch_op: + batch_op.add_column(sa.Column('visibility', sa.Enum('MANDATORY', 'OPTIONAL', 'HIDDEN', name='eventtemplatevisibilityenum'), nullable=False)) + batch_op.add_column(sa.Column('repeat_count', sa.Integer(), nullable=False)) + batch_op.drop_column('mandatory') + + with op.batch_alter_table('event_configurations_history', schema=None) as batch_op: + batch_op.add_column(sa.Column('visibility', sa.Enum('MANDATORY', 'OPTIONAL', 'HIDDEN', name='eventtemplatevisibilityenum'), autoincrement=False, nullable=False)) + batch_op.add_column(sa.Column('repeat_count', sa.Integer(), autoincrement=False, nullable=False)) + batch_op.drop_column('mandatory') + + with op.batch_alter_table('event_templates', schema=None) as batch_op: + batch_op.add_column(sa.Column('visibility', sa.Enum('MANDATORY', 'OPTIONAL', 'HIDDEN', name='eventtemplatevisibilityenum'), nullable=False, comment='Indicate whether the event generated with this template should be autogenerated or available for optional events or added in the back end using actions')) + batch_op.drop_column('mandatory') + + with op.batch_alter_table('event_templates_history', schema=None) as batch_op: + batch_op.add_column(sa.Column('visibility', sa.Enum('MANDATORY', 'OPTIONAL', 'HIDDEN', name='eventtemplatevisibilityenum'), autoincrement=False, nullable=False, comment='Indicate whether the event generated with this template should be autogenerated or available for optional events or added in the back end using actions')) + batch_op.drop_column('mandatory') + + with op.batch_alter_table('phase_codes', schema=None) as batch_op: + batch_op.add_column(sa.Column('visibility', sa.Enum('REGULAR', 'HIDDEN', name='phasevisibilityenum'), nullable=True)) + + with op.batch_alter_table('phase_codes_history', schema=None) as batch_op: + batch_op.add_column(sa.Column('visibility', sa.Enum('REGULAR', 'HIDDEN', name='phasevisibilityenum'), autoincrement=False, nullable=True)) + + with op.batch_alter_table('work_phases', schema=None) as batch_op: + batch_op.add_column(sa.Column('sort_order', sa.Integer(), nullable=False)) + batch_op.add_column(sa.Column('visibility', sa.Enum('REGULAR', 'HIDDEN', name='phasevisibilityenum'), autoincrement=False, nullable=True)) + + with op.batch_alter_table('work_phases_history', schema=None) as batch_op: + batch_op.add_column(sa.Column('sort_order', sa.Integer(), autoincrement=False, nullable=False)) + batch_op.add_column(sa.Column('visibility', sa.Enum('REGULAR', 'HIDDEN', name='phasevisibilityenum'), autoincrement=False, nullable=True)) + + with op.batch_alter_table('works', schema=None) as batch_op: + batch_op.add_column(sa.Column('work_decision_date', sa.DateTime(timezone=True), nullable=True)) + + with op.batch_alter_table('works_history', schema=None) as batch_op: + batch_op.add_column(sa.Column('work_decision_date', sa.DateTime(timezone=True), autoincrement=False, nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('works_history', schema=None) as batch_op: + batch_op.drop_column('work_decision_date') + + with op.batch_alter_table('works', schema=None) as batch_op: + batch_op.drop_column('work_decision_date') + + with op.batch_alter_table('work_phases_history', schema=None) as batch_op: + batch_op.drop_column('visibility') + batch_op.drop_column('sort_order') + + with op.batch_alter_table('work_phases', schema=None) as batch_op: + batch_op.drop_column('visibility') + batch_op.drop_column('sort_order') + + with op.batch_alter_table('phase_codes_history', schema=None) as batch_op: + batch_op.drop_column('visibility') + + with op.batch_alter_table('phase_codes', schema=None) as batch_op: + batch_op.drop_column('visibility') + + with op.batch_alter_table('event_templates_history', schema=None) as batch_op: + batch_op.add_column(sa.Column('mandatory', sa.BOOLEAN(), autoincrement=False, nullable=True)) + batch_op.drop_column('visibility') + + with op.batch_alter_table('event_templates', schema=None) as batch_op: + batch_op.add_column(sa.Column('mandatory', sa.BOOLEAN(), autoincrement=False, nullable=True)) + batch_op.drop_column('visibility') + + with op.batch_alter_table('event_configurations_history', schema=None) as batch_op: + batch_op.add_column(sa.Column('mandatory', sa.BOOLEAN(), autoincrement=False, nullable=True)) + batch_op.drop_column('repeat_count') + batch_op.drop_column('visibility') + + with op.batch_alter_table('event_configurations', schema=None) as batch_op: + batch_op.add_column(sa.Column('mandatory', sa.BOOLEAN(), autoincrement=False, nullable=True)) + batch_op.drop_column('repeat_count') + batch_op.drop_column('visibility') + + # ### end Alembic commands ### diff --git a/epictrack-api/migrations/versions/eacb66ae668b_more_actions.py b/epictrack-api/migrations/versions/eacb66ae668b_more_actions.py new file mode 100644 index 000000000..240c2c791 --- /dev/null +++ b/epictrack-api/migrations/versions/eacb66ae668b_more_actions.py @@ -0,0 +1,31 @@ +"""more actions + +Revision ID: eacb66ae668b +Revises: 14cebe9c6b1f +Create Date: 2023-11-20 13:17:49.673196 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'eacb66ae668b' +down_revision = '14cebe9c6b1f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute("INSERT INTO actions(name)VALUES('ChangePhaseEndEvent')") + op.execute("INSERT INTO actions(name)VALUES('SetFederalInvolvement')") + op.execute("INSERT INTO actions(name)VALUES('SetProjectState')") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + + # ### end Alembic commands ### diff --git a/epictrack-api/src/api/actions/action_handler.py b/epictrack-api/src/api/actions/action_handler.py index 7afb92eb6..94f841fc4 100644 --- a/epictrack-api/src/api/actions/action_handler.py +++ b/epictrack-api/src/api/actions/action_handler.py @@ -39,4 +39,8 @@ def get_additional_params(self, params: dict) -> None: the given params. This is required to extract the actual params at the time of template uploading """ - return self.action_class().get_additional_params(params) if self.action_class else {} + return ( + self.action_class().get_additional_params(params) + if self.action_class + else {} + ) diff --git a/epictrack-api/src/api/actions/add_event.py b/epictrack-api/src/api/actions/add_event.py index 1cdb5f9c7..0c16661bb 100644 --- a/epictrack-api/src/api/actions/add_event.py +++ b/epictrack-api/src/api/actions/add_event.py @@ -3,11 +3,14 @@ from datetime import timedelta from api.actions.base import ActionFactory -from api.models import db +from api.models import db, Event from api.models.event_configuration import EventConfiguration from api.models.phase_code import PhaseCode from api.models.work_phase import WorkPhase -from api.schemas.response.event_configuration_response import EventConfigurationResponseSchema +from api.models.event_template import EventTemplateVisibilityEnum +from api.schemas.response.event_configuration_response import ( + EventConfigurationResponseSchema, +) from api.schemas.response.event_template_response import EventTemplateResponseSchema @@ -17,36 +20,39 @@ class AddEvent(ActionFactory): """Add a new event""" - def run(self, source_event, params) -> None: + def run(self, source_event: Event, params) -> None: """Adds a new event based on params""" from api.services.event import EventService - event_data, work_phase_id = self.get_additional_params(params) + event_data, work_phase_id = self.get_additional_params(source_event, params) event_data.update( { "is_active": True, "work_id": source_event.work_id, - "anticipated_date": source_event.actual_date + timedelta(days=params["start_at"]), + "anticipated_date": source_event.actual_date + + timedelta(days=params["start_at"]), } ) - EventService.create_event(event_data, work_phase_id=work_phase_id, push_events=True) + EventService.create_event( + event_data, work_phase_id=work_phase_id, push_events=True + ) - def get_additional_params(self, params): + def get_additional_params(self, source_event: Event, params): """Returns additional parameter""" from api.services.work import WorkService - phase = { - "name": params.pop("phase_name"), - "work_type_id": params.pop("work_type_id"), - "ea_act_id": params.pop("ea_act_id"), - } - phase_query = ( - db.session.query(PhaseCode).filter_by(**phase, is_active=True).subquery() - ) work_phase = ( db.session.query(WorkPhase) - .join(phase_query, WorkPhase.phase_id == phase_query.c.id) - .filter(WorkPhase.is_active.is_(True)) + .join(PhaseCode, WorkPhase.phase_id == PhaseCode.id) + .filter( + WorkPhase.work_id == source_event.work_id, + PhaseCode.name == params.get("phase_name"), + PhaseCode.work_type_id == params.get("work_type_id"), + PhaseCode.ea_act_id == params.get("ea_act_id"), + WorkPhase.is_active.is_(True), + PhaseCode.is_active.is_(True), + ) + .order_by(WorkPhase.sort_order.desc()) .first() ) old_event_config = ( @@ -56,15 +62,20 @@ def get_additional_params(self, params): EventConfiguration.name == params.pop("event_name"), EventConfiguration.is_active.is_(True), ) + .order_by(EventConfiguration.repeat_count.desc()) .first() ) event_configuration = EventConfigurationResponseSchema().dump(old_event_config) event_configuration["start_at"] = params["start_at"] + event_configuration["visibility"] = EventTemplateVisibilityEnum.MANDATORY.value + event_configuration["repeat_count"] = old_event_config.repeat_count + 1 del event_configuration["id"] event_configuration = EventConfiguration(**event_configuration) event_configuration.flush() - template_json = EventTemplateResponseSchema().dump(old_event_config.event_template) + template_json = EventTemplateResponseSchema().dump( + old_event_config.event_template + ) WorkService.copy_outcome_and_actions(template_json, event_configuration) event_data = { "event_configuration_id": event_configuration.id, diff --git a/epictrack-api/src/api/actions/add_phase.py b/epictrack-api/src/api/actions/add_phase.py index 6ff4f0044..a62df12d0 100644 --- a/epictrack-api/src/api/actions/add_phase.py +++ b/epictrack-api/src/api/actions/add_phase.py @@ -2,10 +2,9 @@ from datetime import timedelta from api.actions.base import ActionFactory -from api.models import db -from api.models.event import Event -from api.models.phase_code import PhaseCode -from api.schemas.response.event_template_response import EventTemplateResponseSchema +from api.models import db, WorkPhase, Event, EventConfiguration +from api.models.phase_code import PhaseCode, PhaseVisibilityEnum +from api.schemas import response as res # pylint: disable= import-outside-toplevel @@ -15,38 +14,81 @@ class AddPhase(ActionFactory): def run(self, source_event: Event, params: dict) -> None: """Adds a new phase based on params""" # Importing here to avoid circular imports - from api.services.event_template import EventTemplateService from api.services.work import WorkService phase_start_date = source_event.actual_date + timedelta(days=1) - work_phase_data = self.get_additional_params(params) + work_phase_data = self.get_additional_params(source_event, params) work_phase_data.update( { "work_id": source_event.work.id, "start_date": f"{phase_start_date}", "end_date": f"{phase_start_date + timedelta(days=work_phase_data['number_of_days'])}", + "sort_order": source_event.event_configuration.work_phase.sort_order + + 1, } ) - event_templates = EventTemplateService.find_by_phase_id( - work_phase_data["phase_id"] + work_phases = ( + db.session.query(WorkPhase) + .filter( + WorkPhase.sort_order + > source_event.event_configuration.work_phase.sort_order, + WorkPhase.work_id == source_event.work_id, + ) + .all() ) - event_template_json = EventTemplateResponseSchema(many=True).dump( - event_templates + for work_phase in work_phases: + work_phase.sort_order = work_phase.sort_order + 1 + work_phase.update(work_phase.as_dict(recursive=False), commit=False) + work_phase = WorkPhase.flush(WorkPhase(**work_phase_data)) + event_configurations = self.get_configurations(source_event, params) + event_configurations = res.EventConfigurationResponseSchema(many=True).dump( + event_configurations ) - WorkService.handle_phase(work_phase_data, event_template_json) + new_event_configurations = WorkService.create_configurations( + work_phase, event_configurations, False + ) + WorkService.create_events_by_configuration(work_phase, new_event_configurations) - def get_additional_params(self, params): + def get_additional_params(self, source_event, params): """Returns additional parameter""" - new_name = params.pop("new_name") - legislated = params.pop("legislated") - params["name"] = params.pop("phase_name") - phase = db.session.query(PhaseCode).filter_by(**params, is_active=True).first() + query_params = { + "name": params.get("phase_name"), + "work_type_id": params.get("work_type_id"), + "ea_act_id": params.get("ea_act_id"), + } + phase = ( + db.session.query(PhaseCode) + .filter_by(**query_params, is_active=True) + .first() + ) work_phase_data = { "phase_id": phase.id, - "name": new_name, - "legislated": legislated, + "name": params.get("new_name"), + "legislated": params.get("legislated"), "number_of_days": phase.number_of_days, + "visibility": PhaseVisibilityEnum.REGULAR.value, } - return work_phase_data + + def get_configurations(self, source_event: Event, params) -> [EventConfiguration]: + """Find the latest event configurations per the given params""" + work_phase = ( + db.session.query(WorkPhase) + .join(PhaseCode, WorkPhase.phase_id == PhaseCode.id) + .filter( + WorkPhase.work_id == source_event.work_id, + PhaseCode.name == params.get("phase_name"), + PhaseCode.work_type_id == params.get("work_type_id"), + PhaseCode.ea_act_id == params.get("ea_act_id"), + WorkPhase.is_active.is_(True), + PhaseCode.is_active.is_(True), + ) + .order_by(WorkPhase.sort_order.desc()) + .first() + ) + + event_configurations = EventConfiguration.find_by_params( + {"work_phase_id": work_phase.id} + ) + return event_configurations diff --git a/epictrack-api/src/api/actions/base.py b/epictrack-api/src/api/actions/base.py index 129f9c998..0859ac77d 100644 --- a/epictrack-api/src/api/actions/base.py +++ b/epictrack-api/src/api/actions/base.py @@ -12,7 +12,9 @@ class ActionFactory(ABC): # pylint: disable=too-few-public-methods def run(self, source_event: Event, params: dict) -> None: """Perform the action""" - def get_additional_params(self, params: dict) -> dict: + def get_additional_params( + self, source_event: Event, params: dict # pylint: disable=unused-argument + ) -> dict: """Returns the derived additional parameters required to perform action from templates""" return params @@ -27,5 +29,7 @@ def get_additional_params(self, params: dict) -> dict: ActionEnum.SET_EVENTS_STATUS: "SetEventsStatus", ActionEnum.SET_PHASES_STATUS: "SetPhasesStatus", ActionEnum.SET_WORK_DECISION_MAKER: "SetWorkDecisionMaker", - ActionEnum.SET_WORK_STATE: "SetWorkState" + ActionEnum.SET_WORK_STATE: "SetWorkState", + ActionEnum.CHANGE_PHASE_END_EVENT: "ChangePhaseEndEvent", + ActionEnum.SET_FEDERAL_INVOLVEMENT: "SetFederalInvolvement", } diff --git a/epictrack-api/src/api/actions/change_phase_end_event.py b/epictrack-api/src/api/actions/change_phase_end_event.py new file mode 100644 index 000000000..da3cf24e1 --- /dev/null +++ b/epictrack-api/src/api/actions/change_phase_end_event.py @@ -0,0 +1,54 @@ +"""Change the phase end event to another one""" + +from api.actions.base import ActionFactory +from api.models import db, Event, PhaseCode, WorkPhase, EventConfiguration +from api.models.event_configuration import EventPositionEnum +from api.models.phase_code import PhaseVisibilityEnum + + +class ChangePhaseEndEvent(ActionFactory): + """Change the phase end event to another one""" + + def run(self, source_event: Event, params) -> None: + """Change the phase end event to another one""" + work_phase = self.get_additional_params(source_event, params) + current_end_event_config = db.session.query(EventConfiguration).filter( + EventConfiguration.work_phase_id == work_phase.id, + EventConfiguration.event_position == EventPositionEnum.END.value, + ) + current_end_event_config.event_position = EventPositionEnum.INTERMEDIATE.value + current_end_event_config.update( + current_end_event_config.as_dict(recursive=False), commit=False + ) + new_end_event_configuration = ( + db.session.query(EventConfiguration) + .filter( + EventConfiguration.work_phase_id == work_phase.id, + EventConfiguration.name == params.get("event_name"), + EventConfiguration.is_active.is_(True), + ) + .first() + ) + new_end_event_configuration.event_position = EventPositionEnum.END.value + new_end_event_configuration.update( + new_end_event_configuration.as_dict(recursive=False), commit=False + ) + + def get_additional_params(self, source_event: Event, params): + """Returns additional parameter""" + work_phase = ( + db.session.query(WorkPhase) + .join(PhaseCode, WorkPhase.phase_id == PhaseCode.id) + .filter( + WorkPhase.work_id == source_event.work_id, + PhaseCode.name == params.get("phase_name"), + PhaseCode.work_type_id == params.get("work_type_id"), + PhaseCode.ea_act_id == params.get("ea_act_id"), + WorkPhase.visibility == PhaseVisibilityEnum.REGULAR.value, + WorkPhase.is_active.is_(True), + PhaseCode.is_active.is_(True), + ) + .order_by(WorkPhase.sort_order.desc()) + .first() + ) + return work_phase diff --git a/epictrack-api/src/api/actions/common.py b/epictrack-api/src/api/actions/common.py new file mode 100644 index 000000000..5dc6d70c6 --- /dev/null +++ b/epictrack-api/src/api/actions/common.py @@ -0,0 +1,35 @@ +"""Common methods for the actions""" + +from api.models import Event, db, WorkPhase, EventConfiguration +from api.models.phase_code import PhaseCode, PhaseVisibilityEnum + + +def find_configuration(source_event: Event, params) -> int: + """Find the configuration""" + work_phase = ( + db.session.query(WorkPhase) + .join(PhaseCode, WorkPhase.phase_id == PhaseCode.id) + .filter( + WorkPhase.work_id == source_event.work_id, + PhaseCode.name == params.get("phase_name"), + PhaseCode.work_type_id == params.get("work_type_id"), + PhaseCode.ea_act_id == params.get("ea_act_id"), + WorkPhase.visibility == PhaseVisibilityEnum.REGULAR.value, + WorkPhase.is_active.is_(True), + PhaseCode.is_active.is_(True), + ) + .order_by(WorkPhase.sort_order.desc()) + .first() + ) + + event_configuration = ( + db.session.query(EventConfiguration) + .filter( + EventConfiguration.work_phase_id == work_phase.id, + EventConfiguration.name == params.get("event_name"), + EventConfiguration.is_active.is_(True), + ) + .order_by(EventConfiguration.repeat_count.desc()) + .first() + ) + return event_configuration diff --git a/epictrack-api/src/api/actions/create_work.py b/epictrack-api/src/api/actions/create_work.py index e86879341..649448cdf 100644 --- a/epictrack-api/src/api/actions/create_work.py +++ b/epictrack-api/src/api/actions/create_work.py @@ -1,6 +1,8 @@ """Create work action handler""" from datetime import timedelta +from pytz import timezone + from api.actions.base import ActionFactory from api.models import db from api.models.work_type import WorkType @@ -20,10 +22,13 @@ def run(self, source_event, params) -> None: ) .first() ) + + start_date = source_event.actual_date + timedelta(days=1) + start_date = start_date.astimezone(timezone('US/Pacific')) new_work = { "ea_act_id": source_event.work.ea_act_id, "work_type_id": work_type.id, - "start_date": source_event.actual_date + timedelta(days=1), + "start_date": start_date, "project_id": source_event.work.project_id, "ministry_id": source_event.work.ministry_id, "federal_involvement_id": source_event.work.federal_involvement_id, diff --git a/epictrack-api/src/api/actions/lock_work_start_date.py b/epictrack-api/src/api/actions/lock_work_start_date.py index 2c0c22540..a2ff012da 100644 --- a/epictrack-api/src/api/actions/lock_work_start_date.py +++ b/epictrack-api/src/api/actions/lock_work_start_date.py @@ -11,6 +11,5 @@ class LockWorkStartDate(ActionFactory): # pylint: disable=too-few-public-method def run(self, source_event, params) -> None: """Set the work start date and mark start date as locked for changes""" db.session.query(Work).filter(Work.id == source_event.work_id).update( - params + {Work.start_date_locked: params.get("start_date_locked")} ) - db.session.commit() diff --git a/epictrack-api/src/api/actions/set_event_date.py b/epictrack-api/src/api/actions/set_event_date.py index 868c6c7c9..79f1df938 100644 --- a/epictrack-api/src/api/actions/set_event_date.py +++ b/epictrack-api/src/api/actions/set_event_date.py @@ -5,45 +5,32 @@ from api.actions.base import ActionFactory from api.models import db from api.models.event import Event -from api.models.event_configuration import EventConfiguration -from api.models.phase_code import PhaseCode -from api.models.work_phase import WorkPhase +from .common import find_configuration class SetEventDate(ActionFactory): # pylint: disable=too-few-public-methods """Sets the event date""" - def run(self, source_event, params: dict) -> None: + def run(self, source_event: Event, params: dict) -> None: """Performs the required operations""" - event_configuration_id = self.get_additional_params(params) - db.session.query(Event).filter( - Event.work_id == source_event.work_id, - Event.is_active.is_(True), - Event.event_configuration_id == event_configuration_id, - ).update({ - "anticipated_date": source_event.actual_date + timedelta(days=1) - }) - db.session.commit() - - def get_additional_params(self, params): - """Returns additional parameter""" - phase = { - "name": params.pop("phase_name"), - "work_type_id": params.pop("work_type_id"), - "ea_act_id": params.pop("ea_act_id"), - } - phase_query = ( - db.session.query(PhaseCode).filter_by(**phase, is_active=True).subquery() + from api.services.event import ( # pylint: disable=import-outside-toplevel + EventService, ) - work_phase = ( - db.session.query(WorkPhase) - .join(phase_query, WorkPhase.phase_id == phase_query.c.id) - .filter(WorkPhase.is_active.is_(True)) + + number_of_days_to_be_added = params.get("start_at") + event_configuration = find_configuration(source_event, params) + event = ( + db.session.query(Event) + .filter( + Event.work_id == source_event.work_id, + Event.is_active.is_(True), + Event.event_configuration_id == event_configuration.id, + ) .first() ) - event_configuration = db.session.query(EventConfiguration).filter( - EventConfiguration.work_phase_id == work_phase.id, - EventConfiguration.name == params["event_name"], - EventConfiguration.is_active.is_(True), - ).first() - return event_configuration.id + event.anticipated_date = source_event.actual_date + timedelta( + days=number_of_days_to_be_added + ) + EventService.update_event( + event.as_dict(recursive=False), event.id, True, commit=False + ) diff --git a/epictrack-api/src/api/actions/set_events_status.py b/epictrack-api/src/api/actions/set_events_status.py index 76be54fb5..2130ee6e4 100644 --- a/epictrack-api/src/api/actions/set_events_status.py +++ b/epictrack-api/src/api/actions/set_events_status.py @@ -3,14 +3,17 @@ from api.models import db from api.models.event import Event +from .common import find_configuration + class SetEventsStatus(ActionFactory): """Set events status action""" def run(self, source_event, params): """Sets all future events to INACTIVE""" - db.session.query(Event).filter( - Event.work_id == source_event.work_id, - Event.anticipated_date >= source_event.actual_date - ).update(params) - db.session.commit() + if isinstance(params, list): + for event_params in params: + event_configuration = find_configuration(source_event, event_params) + db.session.query(Event).filter( + Event.event_configuration_id == event_configuration.id + ).update({Event.is_active: params.get("is_active")}) diff --git a/epictrack-api/src/api/actions/set_federal_involvement.py b/epictrack-api/src/api/actions/set_federal_involvement.py new file mode 100644 index 000000000..aae805529 --- /dev/null +++ b/epictrack-api/src/api/actions/set_federal_involvement.py @@ -0,0 +1,15 @@ +"""Set federal involvement""" +from api.actions.base import ActionFactory +from api.models.event import Event +from api.models.work import Work +from api.models.federal_involvement import FederalInvolvementEnum + + +class SetFederalInvolvement(ActionFactory): + """Sets the federal involvement field to None""" + + def run(self, source_event: Event, params) -> None: + """Sets the federal involvement field to None""" + work = Work.find_by_id(source_event.work_id) + work.federal_involvement_id = FederalInvolvementEnum.NONE + work.update(work.as_dict(recursive=False), commit=False) diff --git a/epictrack-api/src/api/actions/set_phases_status.py b/epictrack-api/src/api/actions/set_phases_status.py index 2c42a88ea..623dccdb5 100644 --- a/epictrack-api/src/api/actions/set_phases_status.py +++ b/epictrack-api/src/api/actions/set_phases_status.py @@ -1,16 +1,77 @@ """Set phases status action handler""" from api.actions.base import ActionFactory -from api.models import db +from api.models import db, PRIMARY_CATEGORIES from api.models.work_phase import WorkPhase +from api.models.event import Event +from api.models.phase_code import PhaseCode, PhaseVisibilityEnum +from api.models.event_configuration import EventConfiguration class SetPhasesStatus(ActionFactory): """Set phases status action""" - def run(self, source_event, params): - """Sets all future PHASEs to INACTIVE""" - db.session.query(WorkPhase).filter( - WorkPhase.work_id == source_event.work_id, - WorkPhase.start_date >= source_event.actual_date - ).update(params) - db.session.commit() + def run(self, source_event: Event, params): + """Sets all future phases to INACTIVE""" + from api.services import EventService # pylint: disable=import-outside-toplevel + work_phase_ids = [] + if isinstance(params, list): + for phase_des in params: + work_phase_ids.append( + self.get_additional_params(source_event, phase_des) + ) + elif params.get("all_future_phases") is not None: + work_phases = ( + db.session.query(WorkPhase) + .filter( + WorkPhase.sort_order + > source_event.event_configuration.work_phase.sort_order + ) + .all() + ) + work_phase_ids = list(map(lambda x: x.id, work_phases)) + # deactivate all the future events in the current phase + events = EventService.find_events( + source_event.work_id, + source_event.event_configuration.work_phase_id, + PRIMARY_CATEGORIES, + ) + event_index = EventService.find_event_index( + events, source_event, source_event.event_configuration.work_phase + ) + events_to_be_updated = events[(event_index + 1):] + for event in events_to_be_updated: + event.is_active = False + event.update(event.as_dict(recursive=False), commit=False) + self._deactivate_phases_and_events(work_phase_ids) + + def get_additional_params(self, source_event: Event, params) -> int: + """Returns additional parameter""" + work_phase = ( + db.session.query(WorkPhase) + .join(PhaseCode, WorkPhase.phase_id == PhaseCode.id) + .filter( + WorkPhase.work_id == source_event.work_id, + PhaseCode.name == params.get("phase_name"), + PhaseCode.work_type_id == params.get("work_type_id"), + PhaseCode.ea_act_id == params.get("ea_act_id"), + WorkPhase.visibility == PhaseVisibilityEnum.REGULAR.value, + WorkPhase.is_active.is_(True), + PhaseCode.is_active.is_(True), + ) + .order_by(WorkPhase.sort_order.desc()) + .first() + ) + return work_phase.id + + def _deactivate_phases_and_events(self, work_phase_ids: [int]) -> None: + """Deactivate given work phases and its events""" + db.session.query(WorkPhase).filter(WorkPhase.id.in_(work_phase_ids)).update( + {WorkPhase.is_active: False} + ) + event_configurations = db.session.query(EventConfiguration).filter( + EventConfiguration.work_phase_id.in_(work_phase_ids) + ) + event_configuration_ids = list(map(lambda x: x.id, event_configurations)) + db.session.query(Event).filter( + Event.event_configuration_id.in_(event_configuration_ids) + ).update({Event.is_active: False}) diff --git a/epictrack-api/src/api/actions/set_project_state.py b/epictrack-api/src/api/actions/set_project_state.py new file mode 100644 index 000000000..018ce88ab --- /dev/null +++ b/epictrack-api/src/api/actions/set_project_state.py @@ -0,0 +1,14 @@ +"""Sets the state of the project""" +from api.actions.base import ActionFactory +from api.models.event import Event +from api.models.project import Project + + +class SetProjectState(ActionFactory): + """Sets the state of the project""" + + def run(self, source_event: Event, params) -> None: + """Sets the federal involvement field to None""" + project = Project.find_by_id(source_event.work.project_id) + project.project_state = params.get("project_state") + project.update(project.as_dict(recursive=False), commit=False) diff --git a/epictrack-api/src/api/actions/set_project_status.py b/epictrack-api/src/api/actions/set_project_status.py index de34d2351..eb55a315f 100644 --- a/epictrack-api/src/api/actions/set_project_status.py +++ b/epictrack-api/src/api/actions/set_project_status.py @@ -12,5 +12,4 @@ def run(self, source_event, params) -> None: """Sets the project's is_active status to False""" db.session.query(Project).filter( Project.id == source_event.work.project_id - ).update(params) - db.session.commit() + ).update({Project.is_active: params.get("is_active")}) diff --git a/epictrack-api/src/api/actions/set_work_decision_maker.py b/epictrack-api/src/api/actions/set_work_decision_maker.py index 367b269c3..438487d8d 100644 --- a/epictrack-api/src/api/actions/set_work_decision_maker.py +++ b/epictrack-api/src/api/actions/set_work_decision_maker.py @@ -1,6 +1,7 @@ """Disable work start date action handler""" from api.actions.base import ActionFactory +from api.models import db, Work class SetWorkDecisionMaker(ActionFactory): # pylint: disable=too-few-public-methods @@ -8,3 +9,6 @@ class SetWorkDecisionMaker(ActionFactory): # pylint: disable=too-few-public-met def run(self, source_event, params: dict) -> None: """Performs the required operations""" + db.session.query(Work).filter(Work.id == source_event.work_id).update( + {Work.decision_maker_position_id: params.get("position_id")} + ) diff --git a/epictrack-api/src/api/actions/set_work_state.py b/epictrack-api/src/api/actions/set_work_state.py index 6d1ab0a6b..85b857375 100644 --- a/epictrack-api/src/api/actions/set_work_state.py +++ b/epictrack-api/src/api/actions/set_work_state.py @@ -10,10 +10,6 @@ class SetWorkState(ActionFactory): def run(self, source_event, params) -> None: """Sets the work as per action configuration""" - work_state = self.get_additional_params(params) - db.session.query(Work).filter(Work.id == source_event.work_id).update(work_state) - db.session.commit() - - def get_additional_params(self, params) -> dict: - """Returns the derived additional parameters required to perform action from templates""" - return {"work_state": params["work_state"]} + db.session.query(Work).filter(Work.id == source_event.work_id).update( + {Work.work_state: params.get("work_state")} + ) diff --git a/epictrack-api/src/api/models/__init__.py b/epictrack-api/src/api/models/__init__.py index ecf82648a..8b2736d6b 100644 --- a/epictrack-api/src/api/models/__init__.py +++ b/epictrack-api/src/api/models/__init__.py @@ -33,7 +33,7 @@ from .event_configuration import EventConfiguration from .event_template import EventTemplate from .event_type import EventType, EventTypeEnum -from .federal_involvement import FederalInvolvement +from .federal_involvement import FederalInvolvement, FederalInvolvementEnum from .indigenous_category import IndigenousCategory from .indigenous_nation import IndigenousNation from .indigenous_work import IndigenousWork diff --git a/epictrack-api/src/api/models/action.py b/epictrack-api/src/api/models/action.py index d6d7ee80c..dea24626a 100644 --- a/epictrack-api/src/api/models/action.py +++ b/epictrack-api/src/api/models/action.py @@ -33,6 +33,9 @@ class ActionEnum(enum.Enum): SET_WORK_DECISION_MAKER = 8 ADD_PHASE = 9 CREATE_WORK = 10 + CHANGE_PHASE_END_EVENT = 11 + SET_FEDERAL_INVOLVEMENT = 12 + SET_PROJECT_STATE = 13 class Action(db.Model, CodeTableVersioned): diff --git a/epictrack-api/src/api/models/event_configuration.py b/epictrack-api/src/api/models/event_configuration.py index c8481dbe2..857f6ac24 100644 --- a/epictrack-api/src/api/models/event_configuration.py +++ b/epictrack-api/src/api/models/event_configuration.py @@ -16,7 +16,7 @@ import sqlalchemy as sa from sqlalchemy.orm import relationship -from api.models.event_template import EventPositionEnum +from api.models.event_template import EventPositionEnum, EventTemplateVisibilityEnum from .base_model import BaseModelVersioned from .db import db @@ -36,9 +36,10 @@ class EventConfiguration(BaseModelVersioned): event_category_id = sa.Column(sa.ForeignKey('event_categories.id'), nullable=False) start_at = sa.Column(sa.String, nullable=True) number_of_days = sa.Column(sa.Integer, default=0, nullable=False) - mandatory = sa.Column(sa.Boolean, default=False) sort_order = sa.Column(sa.Integer, nullable=False) work_phase_id = sa.Column(sa.ForeignKey('work_phases.id'), nullable=True) + visibility = sa.Column(sa.Enum(EventTemplateVisibilityEnum), nullable=False) + repeat_count = sa.Column(sa.Integer, nullable=False, default=0) event_type = relationship('EventType', foreign_keys=[event_type_id], lazy='select') event_category = relationship('EventCategory', foreign_keys=[event_category_id], lazy='select') diff --git a/epictrack-api/src/api/models/event_template.py b/epictrack-api/src/api/models/event_template.py index f3e66c835..c9e053f3a 100644 --- a/epictrack-api/src/api/models/event_template.py +++ b/epictrack-api/src/api/models/event_template.py @@ -28,6 +28,14 @@ class EventPositionEnum(enum.Enum): END = "END" +class EventTemplateVisibilityEnum(enum.Enum): + """Decide whether to show the events using this template or not""" + + MANDATORY = "MANDATORY" + OPTIONAL = "OPTIONAL" + HIDDEN = "HIDDEN" + + class EventTemplate(BaseModelVersioned): """Model class for Event Template.""" @@ -45,8 +53,13 @@ class EventTemplate(BaseModelVersioned): event_category_id = sa.Column(sa.ForeignKey("event_categories.id"), nullable=False) start_at = sa.Column(sa.String, nullable=True) number_of_days = sa.Column(sa.Integer, default=0, nullable=False) - mandatory = sa.Column(sa.Boolean, default=False) sort_order = sa.Column(sa.Integer, nullable=False) + visibility = sa.Column( + sa.Enum(EventTemplateVisibilityEnum), + nullable=False, + comment="Indicate whether the event generated with this template should be\ + autogenerated or available for optional events or added in the back end using actions", + ) phase = relationship("PhaseCode", foreign_keys=[phase_id], lazy="select") event_type = relationship("EventType", foreign_keys=[event_type_id], lazy="select") diff --git a/epictrack-api/src/api/models/federal_involvement.py b/epictrack-api/src/api/models/federal_involvement.py index 59ee7ff70..60b5e532d 100644 --- a/epictrack-api/src/api/models/federal_involvement.py +++ b/epictrack-api/src/api/models/federal_involvement.py @@ -12,13 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. """Model to handle all operations related to Federal Involvement.""" - +import enum from sqlalchemy import Column, Integer from .code_table import CodeTableVersioned from .db import db +class FederalInvolvementEnum(enum.Enum): + """Enum for federal involvement""" + + NONE = 2 + + class FederalInvolvement(db.Model, CodeTableVersioned): """Model class for FederalInvolvement.""" diff --git a/epictrack-api/src/api/models/indigenous_nation.py b/epictrack-api/src/api/models/indigenous_nation.py index c93f0ed0a..42d3ff5e7 100644 --- a/epictrack-api/src/api/models/indigenous_nation.py +++ b/epictrack-api/src/api/models/indigenous_nation.py @@ -15,7 +15,6 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, func from sqlalchemy.orm import relationship -from sqlalchemy_utils import URLType from .code_table import CodeTableVersioned from .db import db @@ -29,8 +28,9 @@ class IndigenousNation(db.Model, CodeTableVersioned): id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(255), nullable=False) is_active = Column(Boolean, default=True, nullable=False) - pip_link = Column(URLType, default=None, nullable=True) + pip_link = Column(String, default=None, nullable=True) notes = Column(String) + bcigid = Column(String) relationship_holder_id = Column( ForeignKey("staffs.id"), nullable=True, default=None diff --git a/epictrack-api/src/api/models/phase_code.py b/epictrack-api/src/api/models/phase_code.py index da31e7439..da26149c6 100644 --- a/epictrack-api/src/api/models/phase_code.py +++ b/epictrack-api/src/api/models/phase_code.py @@ -12,53 +12,61 @@ # See the License for the specific language governing permissions and # limitations under the License. """Model to handle all operations related to Payment Disbursement status code.""" - -from sqlalchemy import Boolean, Column, ForeignKey, Integer, String +import enum +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Enum from sqlalchemy.orm import relationship from .code_table import CodeTableVersioned from .db import db +class PhaseVisibilityEnum(enum.Enum): + """Decide whether to show the phase initially or not""" + + REGULAR = "REGULAR" + HIDDEN = "HIDDEN" + + class PhaseCode(db.Model, CodeTableVersioned): """Model class for Phase.""" - __tablename__ = 'phase_codes' + __tablename__ = "phase_codes" - id = Column(Integer, primary_key=True, autoincrement=True) # TODO check how it can be inherited from parent + id = Column( + Integer, primary_key=True, autoincrement=True + ) # TODO check how it can be inherited from parent name = Column(String(250)) - work_type_id = Column(ForeignKey('work_types.id'), nullable=False) - ea_act_id = Column(ForeignKey('ea_acts.id'), nullable=False) + work_type_id = Column(ForeignKey("work_types.id"), nullable=False) + ea_act_id = Column(ForeignKey("ea_acts.id"), nullable=False) number_of_days = Column(Integer(), default=0) legislated = Column(Boolean()) sort_order = Column(Integer()) color = Column(String(15)) + visibility = Column(Enum(PhaseVisibilityEnum), default=PhaseVisibilityEnum.REGULAR) - work_type = relationship('WorkType', foreign_keys=[work_type_id], lazy='select') - ea_act = relationship('EAAct', foreign_keys=[ea_act_id], lazy='select') + work_type = relationship("WorkType", foreign_keys=[work_type_id], lazy="select") + ea_act = relationship("EAAct", foreign_keys=[ea_act_id], lazy="select") def as_dict(self): """Return Json representation.""" return { - 'id': self.id, - 'name': self.name, - 'sort_order': self.sort_order, - 'number_of_days': self.number_of_days, - 'legislated': self.legislated, - 'work_type': self.work_type.as_dict(), - 'ea_act': self.ea_act.as_dict(), - 'color': self.color + "id": self.id, + "name": self.name, + "sort_order": self.sort_order, + "number_of_days": self.number_of_days, + "legislated": self.legislated, + "work_type": self.work_type.as_dict(), + "ea_act": self.ea_act.as_dict(), + "color": self.color, } @classmethod def find_by_ea_act_and_work_type(cls, _ea_act_id, _work_type_id): """Given a id, this will return code master details.""" - code_table = db.session.query( - PhaseCode - ).filter_by( - work_type_id=_work_type_id, ea_act_id=_ea_act_id, - is_active=True - ).order_by( - PhaseCode.sort_order.asc() - ).all() # pylint: disable=no-member + code_table = ( + db.session.query(PhaseCode) + .filter_by(work_type_id=_work_type_id, ea_act_id=_ea_act_id, is_active=True) + .order_by(PhaseCode.sort_order.asc()) + .all() + ) # pylint: disable=no-member return code_table diff --git a/epictrack-api/src/api/models/project.py b/epictrack-api/src/api/models/project.py index 930f3e6b9..f05aa016f 100644 --- a/epictrack-api/src/api/models/project.py +++ b/epictrack-api/src/api/models/project.py @@ -34,6 +34,8 @@ class ProjectStateEnum(enum.Enum): CARE_AND_MAINTENANCE = "CARE_AND_MAINTENANCE" DECOMMISSION = "DECOMMISSION" UNKNOWN = "UNKNOWN" + CLOSED = "CLOSED" + UNDER_DESIGNATION = "UNDER_DESIGNATION" class Project(BaseModelVersioned): diff --git a/epictrack-api/src/api/models/work.py b/epictrack-api/src/api/models/work.py index 1d7f2f0dc..1f589794d 100644 --- a/epictrack-api/src/api/models/work.py +++ b/epictrack-api/src/api/models/work.py @@ -55,6 +55,7 @@ class Work(BaseModelVersioned): start_date = Column(DateTime(timezone=True)) anticipated_decision_date = Column(DateTime(timezone=True)) decision_date = Column(DateTime(timezone=True)) + work_decision_date = Column(DateTime(timezone=True)) project_id = Column(ForeignKey('projects.id'), nullable=False) ministry_id = Column(ForeignKey('ministries.id'), nullable=False) diff --git a/epictrack-api/src/api/models/work_phase.py b/epictrack-api/src/api/models/work_phase.py index 7078759f8..385fce477 100644 --- a/epictrack-api/src/api/models/work_phase.py +++ b/epictrack-api/src/api/models/work_phase.py @@ -13,31 +13,37 @@ # limitations under the License. """Model to handle all operations related to WorkPhase.""" -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Enum from sqlalchemy.orm import relationship +from .phase_code import PhaseVisibilityEnum from .base_model import BaseModelVersioned class WorkPhase(BaseModelVersioned): """Model class for WorkPhase.""" - __tablename__ = 'work_phases' + __tablename__ = "work_phases" id = Column(Integer, primary_key=True, autoincrement=True) start_date = Column(DateTime(timezone=True)) end_date = Column(DateTime(timezone=True)) is_deleted = Column(Boolean(), default=False, nullable=False) - work_id = Column(ForeignKey('works.id'), nullable=False) - phase_id = Column(ForeignKey('phase_codes.id'), nullable=False) + work_id = Column(ForeignKey("works.id"), nullable=False) + phase_id = Column(ForeignKey("phase_codes.id"), nullable=False) name = Column(String(250)) legislated = Column(Boolean, default=False) - task_added = Column(Boolean, default=False,) + task_added = Column( + Boolean, + default=False, + ) number_of_days = Column(Integer, default=0) is_completed = Column(Boolean, default=False) is_suspended = Column(Boolean, default=False) suspended_date = Column(DateTime(timezone=True)) + sort_order = Column(Integer, nullable=False) + visibility = Column(Enum(PhaseVisibilityEnum), default=PhaseVisibilityEnum.REGULAR) - work = relationship('Work', foreign_keys=[work_id], lazy='select') - phase = relationship('PhaseCode', foreign_keys=[phase_id], lazy='select') + work = relationship("Work", foreign_keys=[work_id], lazy="select") + phase = relationship("PhaseCode", foreign_keys=[phase_id], lazy="select") diff --git a/epictrack-api/src/api/reports/resource_forecast_report.py b/epictrack-api/src/api/reports/resource_forecast_report.py index 7b042ae2c..86c29df1b 100644 --- a/epictrack-api/src/api/reports/resource_forecast_report.py +++ b/epictrack-api/src/api/reports/resource_forecast_report.py @@ -18,9 +18,24 @@ from sqlalchemy.orm import aliased from api.models import ( - EAAct, EAOTeam, Event, FederalInvolvement, PhaseCode, Project, Region, Staff, StaffWorkRole, SubType, Type, Work, - WorkPhase, WorkType, db) + EAAct, + EAOTeam, + Event, + FederalInvolvement, + PhaseCode, + Project, + Region, + Staff, + StaffWorkRole, + SubType, + Type, + Work, + WorkPhase, + WorkType, + db, +) from api.models.event_configuration import EventConfiguration +from api.models.event_template import EventTemplateVisibilityEnum from .report_factory import ReportFactory @@ -67,13 +82,20 @@ def __init__(self, filters): self.report_title = "EAO Resource Forecast" start_event_configurations = ( db.session.query( - func.min(EventConfiguration.id).label("event_configuration_id"), EventConfiguration.work_phase_id + func.min(EventConfiguration.id).label("event_configuration_id"), + EventConfiguration.work_phase_id, ) - .filter(EventConfiguration.mandatory.is_(True), EventConfiguration.start_at == '0') # Is 0 needed? + .filter( + EventConfiguration.visibility + == EventTemplateVisibilityEnum.MANDATORY.value, + EventConfiguration.start_at == "0", + ) # Is 0 needed? .group_by(EventConfiguration.work_phase_id) .all() ) - self.start_event_configurations = [x.event_configuration_id for x in start_event_configurations] + self.start_event_configurations = [ + x.event_configuration_id for x in start_event_configurations + ] self.months = [] self.month_labels = [] self.report_cells = { @@ -281,7 +303,10 @@ def _fetch_data(self, report_date: datetime): # pylint: disable=too-many-locals # Event.event_configuration_id.in_(self.start_event_configurations), ), ) - .join(EventConfiguration, Event.event_configuration_id == EventConfiguration.id) + .join( + EventConfiguration, + Event.event_configuration_id == EventConfiguration.id, + ) .join(work_phase, EventConfiguration.work_phase_id == work_phase.id) .join(SubType, Project.sub_type_id == SubType.id) .join(Type, Project.type_id == Type.id) @@ -332,7 +357,7 @@ def _fetch_data(self, report_date: datetime): # pylint: disable=too-many-locals "sector(sub)" ), Project.fte_positions_operation.label("fte_positions_operation"), - Project.fte_positions_construction.label("fte_positions_construction") + Project.fte_positions_construction.label("fte_positions_construction"), ) .all() ) @@ -343,9 +368,13 @@ def _fetch_data(self, report_date: datetime): # pylint: disable=too-many-locals Event.query.filter( Event.work_id.in_(work_ids), Event.event_configuration_id.in_(self.start_event_configurations), - func.coalesce(Event.actual_date, Event.anticipated_date) <= self.end_date, + func.coalesce(Event.actual_date, Event.anticipated_date) + <= self.end_date, + ) + .join( + EventConfiguration, + Event.event_configuration_id == EventConfiguration.id, ) - .join(EventConfiguration, Event.event_configuration_id == EventConfiguration.id) .join(WorkPhase, EventConfiguration.work_phase_id == WorkPhase.id) .join(PhaseCode, WorkPhase.phase_id == PhaseCode.id) .order_by(func.coalesce(Event.actual_date, Event.anticipated_date)) @@ -366,7 +395,8 @@ def _fetch_data(self, report_date: datetime): # pylint: disable=too-many-locals for index, month in enumerate(self.months[1:]): month_events = list( filter( - lambda x: x.start_date.date() <= month, events[work_id]) # pylint:disable=cell-var-from-loop + lambda x: x.start_date.date() <= month, events[work_id] # pylint:disable=cell-var-from-loop + ) ) month_events = sorted(month_events, key=lambda x: x.start_date) latest_event = month_events[-1] @@ -441,8 +471,13 @@ def _format_data(self, data): # pylint: disable=too-many-locals ), ) .join(WorkType, PhaseCode.work_type_id == WorkType.id) - .join(EventConfiguration, and_(EventConfiguration.work_phase_id == WorkPhase.id, - EventConfiguration.mandatory.is_(True))) + .join( + EventConfiguration, + and_( + EventConfiguration.work_phase_id == WorkPhase.id, + EventConfiguration.visibility == EventTemplateVisibilityEnum.MANDATORY, + ), + ) .join(Event, EventConfiguration.id == Event.event_configuration_id) .filter( and_( @@ -450,7 +485,7 @@ def _format_data(self, data): # pylint: disable=too-many-locals Event.work_id == work_data["work_id"], ) ) - .add_columns(EventConfiguration.id.label('event_configuration_id')) + .add_columns(EventConfiguration.id.label("event_configuration_id")) .group_by(PhaseCode.id, EventConfiguration.id) .order_by(PhaseCode.id.desc()) ) @@ -460,7 +495,10 @@ def _format_data(self, data): # pylint: disable=too-many-locals else: referral_timing_obj = referral_timing_query.first() referral_timing = ( - Event.query.filter(Event.event_configuration_id == referral_timing_obj.event_configuration_id) + Event.query.filter( + Event.event_configuration_id + == referral_timing_obj.event_configuration_id + ) .add_column( func.coalesce(Event.actual_date, Event.anticipated_date).label( "event_start_date" @@ -492,7 +530,7 @@ def _format_data(self, data): # pylint: disable=too-many-locals month_data = work_data.pop(month) color = work_data.pop(f"{month}_color") months.append({"label": month, "phase": month_data, "color": color}) - months = sorted(months, key=lambda x: self.month_labels.index(x['label'])) + months = sorted(months, key=lambda x: self.month_labels.index(x["label"])) work_data["months"] = months response.append(work_data) @@ -506,7 +544,7 @@ def generate_report( data = self._format_data(data) if not data: return {}, None - data = sorted(data, key=lambda k: (k['ea_type_sort_order'], k['project_name'])) + data = sorted(data, key=lambda k: (k["ea_type_sort_order"], k["project_name"])) if return_type == "json" and data: return data, None formatted_data = defaultdict(list) @@ -542,7 +580,8 @@ def generate_report( Paragraph( f"{ea_type_label.upper()}({len(projects)})", normal_style ) - ] + [""] * (len(table_headers[1]) - 1) + ] + + [""] * (len(table_headers[1]) - 1) ) normal_style.textColor = colors.black styles.append(("SPAN", (0, row_index), (-1, row_index))) @@ -557,7 +596,9 @@ def generate_report( table_cells.remove("referral_timing") for cell in table_cells: row.append( - Paragraph(project[cell] if project[cell] else "", body_text_style) + Paragraph( + project[cell] if project[cell] else "", body_text_style + ) ) month_cell_start = len(table_cells) for month_index, month in enumerate(self.month_labels): @@ -567,7 +608,7 @@ def generate_report( row.append(Paragraph(month_data["phase"], body_text_style)) cell_index = month_cell_start + month_index color = month_data["color"][1:] - bg_color = [int(color[i:i + 2], 16) / 255 for i in (0, 2, 4)] + bg_color = [int(color[i: i + 2], 16) / 255 for i in (0, 2, 4)] styles.append( ( "BACKGROUND", @@ -592,7 +633,8 @@ def generate_report( ("ALIGN", (0, 2), (-1, -1), "LEFT"), ("FONTNAME", (0, 2), (-1, -1), "Helvetica"), ("FONTNAME", (0, 0), (-1, 1), "Helvetica-Bold"), - ] + styles + ] + + styles ) ) @@ -627,16 +669,14 @@ def generate_report( pdf_stream.seek(0) return pdf_stream.getvalue(), f"{self.report_title}_{report_date:%Y_%m_%d}.pdf" - def _add_months(self, start: datetime, months: int, set_to_last: bool = True) -> datetime: + def _add_months( + self, start: datetime, months: int, set_to_last: bool = True + ) -> datetime: """Adds x months to given date""" year_offset, month = divmod(start.month + months, 13) if year_offset > 0: month += 1 - result = start.replace( - year=start.year + year_offset, - month=month, - day=1 - ) + result = start.replace(year=start.year + year_offset, month=month, day=1) if set_to_last: result = result.replace(day=monthrange(start.year + year_offset, month)[1]) return result diff --git a/epictrack-api/src/api/resources/event.py b/epictrack-api/src/api/resources/event.py index 8d31f88e2..2ac4abe47 100644 --- a/epictrack-api/src/api/resources/event.py +++ b/epictrack-api/src/api/resources/event.py @@ -22,6 +22,7 @@ from api.services.event import EventService from api.utils import auth, profiletime from api.utils.util import cors_preflight +from api.utils.datetime_helper import get_start_of_day API = Namespace("milestones", description="Milestones") @@ -54,6 +55,8 @@ def post(work_phase_id): """Create a milestone event""" request_json = req.MilestoneEventBodyParameterSchema().load(API.payload) args = req.MilestoneEventPushEventQueryParameterSchema().load(request.args) + request_json["anticipated_date"] = get_start_of_day(request_json.get("anticipated_date")) + request_json["actual_date"] = get_start_of_day(request_json.get("actual_date")) event_response = EventService.create_event(request_json, work_phase_id, args.get("push_events")) return res.EventResponseSchema().dump(event_response), HTTPStatus.CREATED @@ -71,6 +74,8 @@ def put(event_id): """Endpoint to update a milestone event""" request_json = req.MilestoneEventBodyParameterSchema().load(API.payload) args = req.MilestoneEventPushEventQueryParameterSchema().load(request.args) + request_json["anticipated_date"] = get_start_of_day(request_json.get("anticipated_date")) + request_json["actual_date"] = get_start_of_day(request_json.get("actual_date")) event_response = EventService.update_event(request_json, event_id, args.get("push_events")) return res.EventResponseSchema().dump(event_response), HTTPStatus.OK @@ -124,6 +129,8 @@ def post(): """Check for existing works.""" args = req.MilestoneEventCheckQueryParameterSchema().load(request.args) request_json = req.MilestoneEventBodyParameterSchema().load(API.payload) + request_json["anticipated_date"] = get_start_of_day(request_json.get("anticipated_date")) + request_json["actual_date"] = get_start_of_day(request_json.get("actual_date")) event_id = args.get("event_id") result = EventService.check_event(request_json, event_id) return res.EventDateChangePosibilityCheckResponseSchema().dump(result), HTTPStatus.OK diff --git a/epictrack-api/src/api/resources/event_template.py b/epictrack-api/src/api/resources/event_template.py index 6759c0e12..f3c42a6e9 100644 --- a/epictrack-api/src/api/resources/event_template.py +++ b/epictrack-api/src/api/resources/event_template.py @@ -13,18 +13,20 @@ # limitations under the License. """Resource for Event Template endpoints.""" from http import HTTPStatus -from flask_restx import Namespace, Resource, cors + from flask import jsonify, request +from flask_restx import Namespace, Resource, cors + +from api.services import EventTemplateService from api.utils import auth, profiletime from api.utils.util import cors_preflight -from api.services import EventTemplateService API = Namespace("tasks", description="Tasks") -@cors_preflight("GET,POST") -@API.route("", methods=["GET", "POST", "OPTIONS"]) +@cors_preflight("POST") +@API.route("", methods=["POST", "OPTIONS"]) class EventTemplates(Resource): """Endpoints for EventTemplates""" diff --git a/epictrack-api/src/api/resources/indigenous_nation.py b/epictrack-api/src/api/resources/indigenous_nation.py index bd8e9e81e..20afdc427 100644 --- a/epictrack-api/src/api/resources/indigenous_nation.py +++ b/epictrack-api/src/api/resources/indigenous_nation.py @@ -63,7 +63,10 @@ def get(indigenous_nation_id): """Return details of an indigenous nation.""" req.IndigenousNationIdPathParameterSchema().load(request.view_args) indigenous_nation = IndigenousNationService.find(indigenous_nation_id) - return res.IndigenousResponseNationSchema().dump(indigenous_nation), HTTPStatus.OK + return ( + res.IndigenousResponseNationSchema().dump(indigenous_nation), + HTTPStatus.OK, + ) @staticmethod @cors.crossdomain(origin="*") @@ -76,7 +79,10 @@ def put(indigenous_nation_id): indigenous_nation = IndigenousNationService.update_indigenous_nation( indigenous_nation_id, request_json ) - return res.IndigenousResponseNationSchema().dump(indigenous_nation), HTTPStatus.OK + return ( + res.IndigenousResponseNationSchema().dump(indigenous_nation), + HTTPStatus.OK, + ) @staticmethod @cors.crossdomain(origin="*") @@ -101,8 +107,15 @@ class IndigenousNations(Resource): def get(): """Return all indigenous nations.""" args = req.BasicRequestQueryParameterSchema().load(request.args) - indigenous_nations = IndigenousNationService.find_all_indigenous_nations(args.get("is_active")) - return jsonify(res.IndigenousResponseNationSchema(many=True).dump(indigenous_nations)), HTTPStatus.OK + indigenous_nations = IndigenousNationService.find_all_indigenous_nations( + args.get("is_active") + ) + return ( + jsonify( + res.IndigenousResponseNationSchema(many=True).dump(indigenous_nations) + ), + HTTPStatus.OK, + ) @staticmethod @cors.crossdomain(origin="*") @@ -114,4 +127,23 @@ def post(): indigenous_nation = IndigenousNationService.create_indigenous_nation( request_json ) - return res.IndigenousResponseNationSchema().dump(indigenous_nation), HTTPStatus.CREATED + return ( + res.IndigenousResponseNationSchema().dump(indigenous_nation), + HTTPStatus.CREATED, + ) + + +@cors_preflight("POST") +@API.route("/import", methods=["POST", "OPTIONS"]) +class ImportIndigenousNations(Resource): + """Endpoint resource to import indigenous nations.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def post(): + """Import indigenous nations""" + file = request.files["file"] + response = IndigenousNationService.import_indigenous_nations(file) + return response, HTTPStatus.CREATED diff --git a/epictrack-api/src/api/resources/project.py b/epictrack-api/src/api/resources/project.py index 7b3b03b55..8628eb51a 100644 --- a/epictrack-api/src/api/resources/project.py +++ b/epictrack-api/src/api/resources/project.py @@ -144,8 +144,13 @@ def get(project_id): args = req.ProjectFirstNationsQueryParamSchema().load(request.args) work_type_id = args["work_type_id"] work_id = args["work_id"] - first_nations = ProjectService.find_first_nations(project_id, work_id, work_type_id) - return res.IndigenousResponseNationSchema(many=True).dump(first_nations), HTTPStatus.OK + first_nations = ProjectService.find_first_nations( + project_id, work_id, work_type_id + ) + return ( + res.IndigenousResponseNationSchema(many=True).dump(first_nations), + HTTPStatus.OK, + ) @cors_preflight("GET") @@ -162,5 +167,23 @@ def get(project_id): req.ProjectIdPathParameterSchema().load(request.view_args) args = req.ProjectFirstNationsQueryParamSchema().load(request.args) work_id = args["work_id"] - first_nation_availability = ProjectService.check_first_nation_available(project_id, work_id) + first_nation_availability = ProjectService.check_first_nation_available( + project_id, work_id + ) return first_nation_availability, HTTPStatus.OK + + +@cors_preflight("POST") +@API.route("/import", methods=["POST", "OPTIONS"]) +class ImportProjects(Resource): + """Endpoint resource to import projects.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def post(): + """Import projects""" + file = request.files["file"] + response = ProjectService.import_projects(file) + return response, HTTPStatus.CREATED diff --git a/epictrack-api/src/api/resources/proponent.py b/epictrack-api/src/api/resources/proponent.py index 8134d80a1..c86b0de47 100644 --- a/epictrack-api/src/api/resources/proponent.py +++ b/epictrack-api/src/api/resources/proponent.py @@ -104,7 +104,23 @@ def get(): @auth.require @profiletime def post(): - """Create new staff""" + """Create new proponent""" request_json = req.ProponentBodyParameterSchema().load(API.payload) proponent = ProponentService.create_proponent(request_json) return res.ProponentResponseSchema().dump(proponent), HTTPStatus.CREATED + + +@cors_preflight("POST") +@API.route("/import", methods=["POST", "OPTIONS"]) +class ImportProponents(Resource): + """Endpoint resource to import proponents.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def post(): + """Import proponents""" + file = request.files["file"] + response = ProponentService.import_proponents(file) + return response, HTTPStatus.CREATED diff --git a/epictrack-api/src/api/resources/staff.py b/epictrack-api/src/api/resources/staff.py index 53774f5c3..ecc8c6351 100644 --- a/epictrack-api/src/api/resources/staff.py +++ b/epictrack-api/src/api/resources/staff.py @@ -137,3 +137,19 @@ def get(email): if staff: return res.StaffResponseSchema().dump(staff), HTTPStatus.OK raise ResourceNotFoundError(f'Staff with email "{email}" not found') + + +@cors_preflight("POST") +@API.route("/import", methods=["POST", "OPTIONS"]) +class ImportStaffs(Resource): + """Endpoint resource to import staffs.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def post(): + """Import staffs""" + file = request.files["file"] + response = StaffService.import_staffs(file) + return response, HTTPStatus.CREATED diff --git a/epictrack-api/src/api/resources/work.py b/epictrack-api/src/api/resources/work.py index 7ff5ab7da..9d94dae3a 100644 --- a/epictrack-api/src/api/resources/work.py +++ b/epictrack-api/src/api/resources/work.py @@ -24,6 +24,7 @@ from api.services.work_phase import WorkPhaseService from api.utils import auth, profiletime from api.utils.util import cors_preflight +from api.utils.datetime_helper import get_start_of_day API = Namespace("works", description="Works") @@ -71,6 +72,7 @@ def get(): def post(): """Create new work""" request_json = req.WorkBodyParameterSchema().load(API.payload) + request_json["start_date"] = get_start_of_day(request_json["start_date"]) work = WorkService.create_work(request_json) return res.WorkResponseSchema().dump(work), HTTPStatus.CREATED diff --git a/epictrack-api/src/api/schemas/request/__init__.py b/epictrack-api/src/api/schemas/request/__init__.py index c3d04cffa..c1fef6d0c 100644 --- a/epictrack-api/src/api/schemas/request/__init__.py +++ b/epictrack-api/src/api/schemas/request/__init__.py @@ -16,7 +16,7 @@ from .action_configuration_request import ActionConfigurationBodyParameterSchema from .action_template_request import ActionTemplateBodyParameterSchema from .base import BasicRequestQueryParameterSchema -from .event_configuration_request import EventConfigurationQueryParamSchema +from .event_configuration_request import EventConfigurationQueryParamSchema, EventConfigurationBodyParamSchema from .event_request import ( MilestoneEventBodyParameterSchema, MilestoneEventBulkDeleteQueryParamSchema, MilestoneEventCheckQueryParameterSchema, MilestoneEventPathParameterSchema, diff --git a/epictrack-api/src/api/schemas/request/action_configuration_request.py b/epictrack-api/src/api/schemas/request/action_configuration_request.py index cb494590b..bc18db2bd 100644 --- a/epictrack-api/src/api/schemas/request/action_configuration_request.py +++ b/epictrack-api/src/api/schemas/request/action_configuration_request.py @@ -30,7 +30,7 @@ class ActionConfigurationBodyParameterSchema(RequestBodyParameterSchema): required=True ) - additional_params = fields.Dict( + additional_params = fields.Raw( metadata={"description": "Additional parameters for the action"} ) diff --git a/epictrack-api/src/api/schemas/request/action_template_request.py b/epictrack-api/src/api/schemas/request/action_template_request.py index 8a0bc93f7..49b9d7399 100644 --- a/epictrack-api/src/api/schemas/request/action_template_request.py +++ b/epictrack-api/src/api/schemas/request/action_template_request.py @@ -30,7 +30,7 @@ class ActionTemplateBodyParameterSchema(RequestBodyParameterSchema): required=True ) - additional_params = fields.Dict( + additional_params = fields.Raw( metadata={"description": "Additional parameters for the action"} ) diff --git a/epictrack-api/src/api/schemas/request/event_configuration_request.py b/epictrack-api/src/api/schemas/request/event_configuration_request.py index 796d3483e..45804ff1b 100644 --- a/epictrack-api/src/api/schemas/request/event_configuration_request.py +++ b/epictrack-api/src/api/schemas/request/event_configuration_request.py @@ -1,7 +1,7 @@ """Event resource's input validations""" from marshmallow import fields, validate -from .base import RequestQueryParameterSchema +from .base import RequestQueryParameterSchema, RequestBodyParameterSchema class EventConfigurationQueryParamSchema(RequestQueryParameterSchema): @@ -16,3 +16,37 @@ class EventConfigurationQueryParamSchema(RequestQueryParameterSchema): mandatory = fields.Bool( metadata={"description": "Mandatory configurations or not"}, ) + + +class EventConfigurationBodyParamSchema(RequestBodyParameterSchema): + """EventConfiguration body parameter schema""" + + name = fields.Str( + metadata={"description": "Name of the event configuration"}, required=True + ) + parent_id = fields.Int( + metadata={"description": "Parent id of the configuration"}, allow_none=True + ) + template_id = fields.Int( + metadata={ + "description": "Id of the corresponding template of this configuration" + }, + required=True, + ) + event_type_id = fields.Int( + metadata={"description": "Id of the event type"}, required=True + ) + event_category_id = fields.Int(metadata={"description": "Id of the event category"}) + start_at = fields.Int( + metadata={"description": "Number of days at which the event has to started"} + ) + number_of_days = fields.Int(metadata={"description": "Number of days of the event"}) + sort_order = fields.Int(metadata={"description": "Sort order of the event"}) + visibility = fields.String( + metadata={"description": "Indicate if the phase is visible in the work plan"}, + required=True, + ) + work_phase_id = fields.Int(metadata={"description": "Work phase id of the event"}) + repeat_count = fields.Int( + metadata={"description": "Repeat count of the same configuration"} + ) diff --git a/epictrack-api/src/api/schemas/request/event_template_request.py b/epictrack-api/src/api/schemas/request/event_template_request.py index 3bc1f60be..daf73c4d4 100644 --- a/epictrack-api/src/api/schemas/request/event_template_request.py +++ b/epictrack-api/src/api/schemas/request/event_template_request.py @@ -72,3 +72,7 @@ class EventTemplateBodyParameterSchema(RequestBodyParameterSchema): multiple_days = fields.Bool( metadata={"description": "Indicate if it is a multi day event"} ) + + visibility = fields.Str( + metadata={"description": "Indicate whether the event to be shown in the workplan or not"} + ) diff --git a/epictrack-api/src/api/schemas/request/phase_request.py b/epictrack-api/src/api/schemas/request/phase_request.py index 084d4aa8f..1833e4c7e 100644 --- a/epictrack-api/src/api/schemas/request/phase_request.py +++ b/epictrack-api/src/api/schemas/request/phase_request.py @@ -56,6 +56,11 @@ class PhaseBodyParameterSchema(RequestBodyParameterSchema): required=True ) + visibility = fields.String( + metadata={"description": "Indicate if the phase is visible in the work plan"}, + required=True, + ) + is_active = fields.Bool( metadata={"description": "Active state of the task"}, ) diff --git a/epictrack-api/src/api/schemas/response/event_configuration_response.py b/epictrack-api/src/api/schemas/response/event_configuration_response.py index 69f591042..722762576 100644 --- a/epictrack-api/src/api/schemas/response/event_configuration_response.py +++ b/epictrack-api/src/api/schemas/response/event_configuration_response.py @@ -31,6 +31,12 @@ class Meta(AutoSchemaBase.Meta): unknown = EXCLUDE event_position = fields.Method("get_event_position") + visibility = fields.Method("get_visibility") + + def get_visibility(self, obj: EventConfiguration) -> str: + """Return value for the visibility""" + return obj.visibility if isinstance(obj.visibility, str) else obj.visibility.value + def get_event_position(self, obj: EventConfiguration) -> str: """Return the work state""" return obj.event_position.value if obj.event_position else None diff --git a/epictrack-api/src/api/schemas/response/event_template_response.py b/epictrack-api/src/api/schemas/response/event_template_response.py index 641ff0c4e..7922e8212 100644 --- a/epictrack-api/src/api/schemas/response/event_template_response.py +++ b/epictrack-api/src/api/schemas/response/event_template_response.py @@ -31,7 +31,18 @@ class Meta(AutoSchemaBase.Meta): unknown = EXCLUDE event_position = fields.Method("get_event_position") + visibility = fields.Method("get_visibility") def get_event_position(self, obj: EventTemplate) -> str: """Return value for the event position""" - return obj.event_position if isinstance(obj.event_position, str) else obj.event_position.value + return ( + obj.event_position + if isinstance(obj.event_position, str) + else obj.event_position.value + ) + + def get_visibility(self, obj: EventTemplate) -> str: + """Return value for the visibility""" + return ( + obj.visibility if isinstance(obj.visibility, str) else obj.visibility.value + ) diff --git a/epictrack-api/src/api/schemas/response/phase_response.py b/epictrack-api/src/api/schemas/response/phase_response.py index 17ace27ef..1b5b65014 100644 --- a/epictrack-api/src/api/schemas/response/phase_response.py +++ b/epictrack-api/src/api/schemas/response/phase_response.py @@ -21,3 +21,8 @@ class Meta(AutoSchemaBase.Meta): ea_act = fields.Nested(EAActSchema, dump_only=True) work_type = fields.Nested(WorkTypeSchema, dump_only=True) + visibility = fields.Method("get_visibility") + + def get_visibility(self, obj: PhaseCode) -> str: + """Return value for the visibility""" + return obj.visibility if isinstance(obj.visibility, str) else obj.visibility.value diff --git a/epictrack-api/src/api/schemas/response/work_response.py b/epictrack-api/src/api/schemas/response/work_response.py index 1103fc782..665e7228d 100644 --- a/epictrack-api/src/api/schemas/response/work_response.py +++ b/epictrack-api/src/api/schemas/response/work_response.py @@ -30,6 +30,12 @@ class Meta(AutoSchemaBase.Meta): unknown = EXCLUDE phase = fields.Nested(PhaseResponseSchema) + visibility = fields.Method("get_visibility") + + def get_visibility(self, obj: WorkPhase) -> str: + """Return value for the visibility""" + return obj.visibility if isinstance(obj.visibility, str) else obj.visibility.value + class WorkResponseSchema( AutoSchemaBase diff --git a/epictrack-api/src/api/services/__init__.py b/epictrack-api/src/api/services/__init__.py index d16523a38..1b736c033 100644 --- a/epictrack-api/src/api/services/__init__.py +++ b/epictrack-api/src/api/services/__init__.py @@ -37,3 +37,4 @@ from .act_section import ActSectionService from .work_status import WorkStatusService from .work_issues import WorkIssuesService +from .event import EventService diff --git a/epictrack-api/src/api/services/event.py b/epictrack-api/src/api/services/event.py index 452845130..cb4860d96 100644 --- a/epictrack-api/src/api/services/event.py +++ b/epictrack-api/src/api/services/event.py @@ -28,12 +28,11 @@ from api.models.action import Action, ActionEnum from api.models.action_configuration import ActionConfiguration from api.models.event_template import EventPositionEnum -from api.models.phase_code import PhaseCode +from api.models.phase_code import PhaseCode, PhaseVisibilityEnum from api.models.project import Project from api.models.work_type import WorkType from api.services.outcome_configuration import OutcomeConfigurationService from api.utils import util -from api.utils.datetime_helper import get_start_of_day from .event_configuration import EventConfigurationService @@ -53,8 +52,6 @@ def create_event( current_work_phase.work_id, None, PRIMARY_CATEGORIES, True ) data["work_id"] = current_work_phase.work_id - data["anticipated_date"] = get_start_of_day(data.get("anticipated_date")) - data["actual_date"] = get_start_of_day(data.get("actual_date")) event = Event(**data) event = event.flush() cls._process_events( @@ -82,8 +79,6 @@ def update_event( raise ResourceNotFoundError("Event not found") if not event.is_active: raise UnprocessableEntityError("Event is inactive and cannot be updated") - data["anticipated_date"] = get_start_of_day(data.get("anticipated_date")) - data["actual_date"] = get_start_of_day(data.get("actual_date")) event = event.update(data, commit=False) # Do not process the date logic if the event is already locked(has actual date entered) if not event_old.actual_date: @@ -106,8 +101,6 @@ def check_event(cls, data: dict, event_id: int = None): if event_id: event = Event.find_by_id(event_id) event_old = copy.copy(event) - data["anticipated_date"] = get_start_of_day(data.get("anticipated_date")) - data["actual_date"] = get_start_of_day(data.get("actual_date")) event_to_check = Event(**data) result = { "subsequent_event_push_required": False, @@ -169,7 +162,7 @@ def _validate_event_effect_on_dates( current_work_phase_index = util.find_index_in_array( all_work_phases, current_work_phase ) - current_event_index = cls._find_event_index( + current_event_index = cls.find_event_index( all_work_events, event_old if event_old else event, current_work_phase ) work_phases_to_be_checked = [all_work_phases[current_work_phase_index]] @@ -257,8 +250,9 @@ def _process_events( """Process the event date logic""" cls._end_event_anticipated_change_rule(event, event_old) all_work_phases = WorkPhase.find_by_params( - {"work_id": current_work_phase.work_id} + {"work_id": current_work_phase.work_id, "visibility": PhaseVisibilityEnum.REGULAR.value} ) + all_work_phases = sorted(all_work_phases, key=lambda x: x.sort_order) current_work_phase_index = util.find_index_in_array( all_work_phases, current_work_phase ) @@ -292,7 +286,7 @@ def _process_events( current_work_phase.as_dict(recursive=False), commit=False ) - current_event_index = cls._find_event_index( + current_event_index = cls.find_event_index( all_work_events, event_old if event_old else event, current_work_phase ) if number_of_days_to_be_pushed != 0 and push_events: @@ -380,7 +374,7 @@ def event_compare_func(cls, event_x, event_y): return 0 @classmethod - def _find_event_index( + def find_event_index( cls, all_work_events: [Event], event: Event, current_work_phase: WorkPhase ): """Find the index of given event in the list of existing events""" @@ -590,7 +584,7 @@ def _previous_event_acutal_date_rule( # pylint: disable=too-many-arguments raise UnprocessableEntityError( "Previous event should be completed to proceed" ) - event_index = cls._find_event_index( + event_index = cls.find_event_index( all_work_events, event_old if event_old else event, all_work_phases[current_work_phase_index], diff --git a/epictrack-api/src/api/services/event_configuration.py b/epictrack-api/src/api/services/event_configuration.py index 56662a12a..33293a993 100644 --- a/epictrack-api/src/api/services/event_configuration.py +++ b/epictrack-api/src/api/services/event_configuration.py @@ -17,6 +17,7 @@ from api.models import EventConfiguration, WorkPhase, db from api.models.event_category import EventCategoryEnum +from api.models.event_template import EventTemplateVisibilityEnum class EventConfigurationService: # pylint: disable=dangerous-default-value,too-many-arguments @@ -45,8 +46,10 @@ def find_configurations( if len(event_categories) > 0: category_ids = list(map(lambda x: x.value, event_categories)) query = query.filter(EventConfiguration.event_category_id.in_(category_ids)) - if mandatory is not None: - query = query.filter(EventConfiguration.mandatory.is_(mandatory)) + if mandatory: + query = query.filter(EventConfiguration.visibility == EventTemplateVisibilityEnum.MANDATORY.value) + if not mandatory: + query = query.filter(EventConfiguration.visibility == EventTemplateVisibilityEnum.OPTIONAL.value) if not _all: query = query.filter(EventConfiguration.parent_id.is_(None)) configurations = query.all() diff --git a/epictrack-api/src/api/services/event_template.py b/epictrack-api/src/api/services/event_template.py index b78321d60..b322533bd 100644 --- a/epictrack-api/src/api/services/event_template.py +++ b/epictrack-api/src/api/services/event_template.py @@ -20,7 +20,17 @@ from api.exceptions import BadRequestError from api.models import ( - Action, ActionTemplate, EAAct, EventCategory, EventTemplate, EventType, OutcomeTemplate, PhaseCode, WorkType, db) + Action, + ActionTemplate, + EAAct, + EventCategory, + EventTemplate, + EventType, + OutcomeTemplate, + PhaseCode, + WorkType, + db, +) from api.schemas import request as req from api.schemas import response as res from api.services.phaseservice import PhaseService @@ -100,8 +110,9 @@ def import_events_template(cls, configuration_file): parent_events = copy.deepcopy( list( filter( - lambda x, _phase_no=phase["no"]: "phase_no" in x and - x["phase_no"] == _phase_no and not x["parent_id"], + lambda x, _phase_no=phase["no"]: "phase_no" in x + and x["phase_no"] == _phase_no + and not x["parent_id"], event_dict, ) ) @@ -120,7 +131,8 @@ def import_events_template(cls, configuration_file): child_events = copy.deepcopy( list( filter( - lambda x, _parent_id=event["no"]: "parent_id" in x and x["parent_id"] == _parent_id, + lambda x, _parent_id=event["no"]: "parent_id" in x + and x["parent_id"] == _parent_id, event_dict, ) ) @@ -261,7 +273,8 @@ def _handle_outcomes( ( e for e in existing_outcomes - if e.name == outcome["name"] and e.event_template_id == outcome["event_template_id"] + if e.name == outcome["name"] + and e.event_template_id == outcome["event_template_id"] ), None, ) @@ -280,7 +293,8 @@ def _handle_outcomes( actions_list = copy.deepcopy( list( filter( - lambda x, _outcome_no=outcome["no"]: x["outcome_no"] == _outcome_no, + lambda x, _outcome_no=outcome["no"]: x["outcome_no"] + == _outcome_no, action_dict.to_dict("records"), ) ) @@ -290,7 +304,9 @@ def _handle_outcomes( ( e for e in existing_actions - if e.action_id == action["action_id"] and e.outcome_id == action["outcome_id"] + if e.action_id == action["action_id"] + and e.outcome_id == action["outcome_id"] + and e.sort_order == action["sort_order"] ), None, ) @@ -315,9 +331,11 @@ def _save_event_template( ( e for e in existing_events - if e.name == event["name"] and e.phase_id == phase_id and - (e.parent_id == parent_id) and e.event_type_id == event["event_type_id"] and - e.event_category_id == event["event_category_id"] + if e.name == event["name"] + and e.phase_id == phase_id + and (e.parent_id == parent_id) + and e.event_type_id == event["event_type_id"] + and e.event_category_id == event["event_category_id"] ), None, ) @@ -343,6 +361,7 @@ def _read_excel(cls, configuration_file: IO) -> Dict[str, pd.DataFrame]: "Color": "color", "SortOrder": "sort_order", "Legislated": "legislated", + "Visibility": "visibility", }, "events": { "No": "no", @@ -356,7 +375,7 @@ def _read_excel(cls, configuration_file: IO) -> Dict[str, pd.DataFrame]: "MultipleDays": "multiple_days", "NumberOfDays": "number_of_days", "StartAt": "start_at", - "Mandatory": "mandatory", + "Visibility": "visibility", "SortOrder": "sort_order", }, "outcomes": { @@ -392,7 +411,9 @@ def _read_excel(cls, configuration_file: IO) -> Dict[str, pd.DataFrame]: except ValueError as exc: raise BadRequestError( "Sheets missing in the imported excel.\ - Required sheets are [" + ",".join(sheets) + "]" + Required sheets are [" + + ",".join(sheets) + + "]" ) from exc return result diff --git a/epictrack-api/src/api/services/indigenous_nation.py b/epictrack-api/src/api/services/indigenous_nation.py index 267447f27..20383327c 100644 --- a/epictrack-api/src/api/services/indigenous_nation.py +++ b/epictrack-api/src/api/services/indigenous_nation.py @@ -12,8 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. """Service to manage IndigenousNation.""" +from typing import IO, List + +import numpy as np +import pandas as pd +from sqlalchemy import text + from api.exceptions import ResourceExistsError, ResourceNotFoundError -from api.models import IndigenousNation +from api.models import IndigenousNation, db +from api.models.pip_org_type import PIPOrgType +from api.models.staff import Staff +from api.utils.token_info import TokenInfo class IndigenousNationService: @@ -35,7 +44,9 @@ def find(cls, indigenous_nation_id): """Find by indigenous nation id.""" indigenous_nation = IndigenousNation.find_by_id(indigenous_nation_id) if not indigenous_nation: - raise ResourceNotFoundError(f"Indigenous nation with id '{indigenous_nation_id}' not found.") + raise ResourceNotFoundError( + f"Indigenous nation with id '{indigenous_nation_id}' not found." + ) return indigenous_nation @classmethod @@ -56,7 +67,9 @@ def update_indigenous_nation(cls, indigenous_nation_id: int, payload: dict): raise ResourceExistsError("Indigenous nation with same name exists") indigenous_nation = IndigenousNation.find_by_id(indigenous_nation_id) if not indigenous_nation: - raise ResourceNotFoundError(f"Indigenous nation with id '{indigenous_nation_id}' not found") + raise ResourceNotFoundError( + f"Indigenous nation with id '{indigenous_nation_id}' not found" + ) indigenous_nation = indigenous_nation.update(payload) return indigenous_nation @@ -67,3 +80,64 @@ def delete_indigenous_nation(cls, indigenous_nation_id: int): indigenous_nation.is_deleted = True indigenous_nation.save() return True + + @classmethod + def import_indigenous_nations(cls, file: IO): + """Import indigenous nations""" + data = cls._read_excel(file) + db.session.execute(text("TRUNCATE indigenous_nations RESTART IDENTITY CASCADE")) + data["relationship_holder_id"] = data.apply(lambda x: x["relationship_holder_id"].lower(), axis=1) + relationship_holders = data["relationship_holder_id"].to_list() + pip_org_types = data["pip_org_type_id"].to_list() + staffs = ( + db.session.query(Staff) + .filter(Staff.email.in_(relationship_holders), Staff.is_active.is_(True)) + .all() + ) + org_types = ( + db.session.query(PIPOrgType) + .filter(PIPOrgType.name.in_(pip_org_types), PIPOrgType.is_active.is_(True)) + .all() + ) + data["relationship_holder_id"] = data.apply(lambda x: cls._find_staff_id(x["relationship_holder_id"], staffs), + axis=1) + data["pip_org_type_id"] = data.apply(lambda x: cls._find_org_type_id(x["pip_org_type_id"], org_types), axis=1) + username = TokenInfo.get_username() + data["created_by"] = username + data = data.to_dict("records") + db.session.bulk_insert_mappings(IndigenousNation, data) + db.session.commit() + return "Created successfully" + + @classmethod + def _read_excel(cls, file: IO) -> pd.DataFrame: + """Read the template excel file""" + column_map = { + "Name": "name", + "Relationship Holder": "relationship_holder_id", + "PIP Link": "pip_link", + "Notes": "notes", + "PIP Org Type": "pip_org_type_id", + "BCIGID": "bcigid", + } + data_frame = pd.read_excel(file) + data_frame = data_frame.drop("Order", axis=1) + data_frame = data_frame.replace({np.nan: None}) + data_frame.rename(column_map, axis="columns", inplace=True) + return data_frame + + @classmethod + def _find_staff_id(cls, email: str, staffs: List[Staff]) -> int: + """Find and return the id of staff from given list""" + staff = next((x for x in staffs if x.email == email), None) + if staff is None: + raise ResourceNotFoundError(f"Staff with email {email} does not exist") + return staff.id + + @classmethod + def _find_org_type_id(cls, name: str, org_types: List[PIPOrgType]) -> int: + """Find and return the id of org_type from given list""" + org_type = next((x for x in org_types if x.name == name), None) + if org_type is None: + raise ResourceNotFoundError(f"Org type with name {name} does not exist") + return org_type.id diff --git a/epictrack-api/src/api/services/project.py b/epictrack-api/src/api/services/project.py index 9c0b08c64..240bb257b 100644 --- a/epictrack-api/src/api/services/project.py +++ b/epictrack-api/src/api/services/project.py @@ -12,15 +12,25 @@ # See the License for the specific language governing permissions and # limitations under the License. """Service to manage Project.""" +from typing import IO, List + +import numpy as np +import pandas as pd from flask import current_app -from sqlalchemy import and_ +from sqlalchemy import and_, text from api.exceptions import ResourceExistsError, ResourceNotFoundError from api.models import Project, db from api.models.indigenous_nation import IndigenousNation from api.models.indigenous_work import IndigenousWork +from api.models.proponent import Proponent +from api.models.region import Region +from api.models.sub_types import SubType +from api.models.types import Type from api.models.work import Work from api.models.work_type import WorkType +from api.utils.constants import PROJECT_STATE_ENUM_MAPS +from api.utils.token_info import TokenInfo class ProjectService: @@ -144,3 +154,133 @@ def check_first_nation_available(cls, project_id: int, work_id: int) -> bool: ) result = result.count() > 0 return {"first_nation_available": result} + + @classmethod + def import_projects(cls, file: IO): + """Import proponents""" + data = cls._read_excel(file) + db.session.execute(text("TRUNCATE projects RESTART IDENTITY CASCADE")) + + proponent_names = set(data["proponent_id"].to_list()) + type_names = set(data["type_id"].to_list()) + sub_type_names = set(data["sub_type_id"].to_list()) + env_region_names = set(data["region_id_env"].to_list()) + flnro_region_names = set(data["region_id_flnro"].to_list()) + proponents = ( + db.session.query(Proponent) + .filter(Proponent.name.in_(proponent_names), Proponent.is_active.is_(True)) + .all() + ) + types = ( + db.session.query(Type) + .filter(Type.name.in_(type_names), Type.is_active.is_(True)) + .all() + ) + sub_types = ( + db.session.query(SubType) + .filter(SubType.name.in_(sub_type_names), SubType.is_active.is_(True)) + .all() + ) + regions = ( + db.session.query(Region) + .filter(Region.name.in_(env_region_names.union(flnro_region_names)), Region.is_active.is_(True)) + .all() + ) + + data["proponent_id"] = data.apply( + lambda x: cls._find_proponent_id(x["proponent_id"], proponents), axis=1 + ) + data["type_id"] = data.apply( + lambda x: cls._find_type_id(x["type_id"], types), axis=1 + ) + data["sub_type_id"] = data.apply( + lambda x: cls._find_sub_type_id(x["sub_type_id"], sub_types), axis=1 + ) + data["region_id_env"] = data.apply( + lambda x: cls._find_region_id(x["region_id_env"], regions, "ENV"), axis=1 + ) + data["region_id_flnro"] = data.apply( + lambda x: cls._find_region_id(x["region_id_flnro"], regions, "FLNR"), axis=1 + ) + data["project_state"] = data.apply( + lambda x: PROJECT_STATE_ENUM_MAPS[x["project_state"]], axis=1 + ) + + username = TokenInfo.get_username() + data["created_by"] = username + data = data.to_dict("records") + db.session.bulk_insert_mappings(Project, data) + db.session.commit() + return "Created successfully" + + @classmethod + def _read_excel(cls, file: IO) -> pd.DataFrame: + """Read the template excel file""" + column_map = { + "Name": "name", + "Proponent": "proponent_id", + "Type": "type_id", + "SubType": "sub_type_id", + "Description": "description", + "Address": "address", + "Latitude": "latitude", + "Longitude": "longitude", + "ENVRegion": "region_id_env", + "FLNRORegion": "region_id_flnro", + "Capital Investment": "capital_investment", + "EPIC Guid": "epic_guid", + "Abbreviation": "abbreviation", + "EACertificate": "ea_certificate", + "Project Closed": "is_project_closed", + "FTE Positions Construction": "fte_positions_construction", + "FTE Positions Operation": "fte_positions_operation", + "Project State": "project_state" + } + data_frame = pd.read_excel(file) + data_frame.rename(column_map, axis="columns", inplace=True) + data_frame = data_frame.infer_objects() + data_frame = data_frame.apply(lambda x: x.str.strip() if x.dtype == "object" else x) + data_frame = data_frame.replace({np.nan: None}) + data_frame = data_frame.replace({np.NaN: None}) + return data_frame + + @classmethod + def _find_proponent_id(cls, name: str, proponents: List[Proponent]) -> int: + """Find and return the id of proponent from given list""" + if name is None: + return None + proponent = next((x for x in proponents if x.name == name), None) + if proponent is None: + print(f"Proponent with name {name} does not exist") + raise ResourceNotFoundError(f"Proponent with name {name} does not exist") + return proponent.id + + @classmethod + def _find_type_id(cls, name: str, types: List[Type]) -> int: + """Find and return the id of type from given list""" + if name is None: + return None + type_obj = next((x for x in types if x.name == name), None) + if type_obj is None: + raise ResourceNotFoundError(f"Type with name {name} does not exist") + return type_obj.id + + @classmethod + def _find_sub_type_id(cls, name: str, sub_types: List[SubType]) -> int: + """Find and return the id of SubType from given list""" + if name is None: + return None + sub_type = next((x for x in sub_types if x.name == name), None) + if sub_type is None: + raise ResourceNotFoundError(f"SubType with name {name} does not exist") + return sub_type.id + + @classmethod + def _find_region_id(cls, name: str, regions: List[Region], entity: str) -> int: + """Find and return the id of region from given list""" + if name is None: + return None + region = next((x for x in regions if x.name == name and x.entity == entity), None) + if region is None: + raise ResourceNotFoundError(f"Region with name {name} does not exist") + return region.id diff --git a/epictrack-api/src/api/services/proponent.py b/epictrack-api/src/api/services/proponent.py index 78e95ea8c..d07184a6a 100644 --- a/epictrack-api/src/api/services/proponent.py +++ b/epictrack-api/src/api/services/proponent.py @@ -12,8 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. """Service to manage Proponent.""" +from typing import IO, List + +import numpy as np +import pandas as pd +from sqlalchemy import text + from api.exceptions import ResourceExistsError, ResourceNotFoundError -from api.models import Proponent +from api.models import Proponent, db +from api.models.staff import Staff +from api.utils.token_info import TokenInfo class ProponentService: @@ -56,7 +64,9 @@ def update_proponent(cls, proponent_id: int, payload: dict): raise ResourceExistsError("Proponent with same name exists") proponent = Proponent.find_by_id(proponent_id) if not proponent: - raise ResourceNotFoundError(f"Proponent with id '{proponent_id}' not found.") + raise ResourceNotFoundError( + f"Proponent with id '{proponent_id}' not found." + ) proponent = proponent.update(payload) return proponent @@ -67,3 +77,53 @@ def delete_proponent(cls, proponent_id: int): proponent.is_deleted = True proponent.save() return True + + @classmethod + def import_proponents(cls, file: IO): + """Import proponents""" + data = cls._read_excel(file) + db.session.execute(text("TRUNCATE proponents RESTART IDENTITY CASCADE")) + data["relationship_holder_id"] = data.apply( + lambda x: x["relationship_holder_id"].lower() + if x["relationship_holder_id"] + else None, + axis=1, + ) + relationship_holders = data["relationship_holder_id"].to_list() + staffs = ( + db.session.query(Staff) + .filter(Staff.email.in_(relationship_holders), Staff.is_active.is_(True)) + .all() + ) + + data["relationship_holder_id"] = data.apply( + lambda x: cls._find_staff_id(x["relationship_holder_id"], staffs), axis=1 + ) + username = TokenInfo.get_username() + data["created_by"] = username + data = data.to_dict("records") + db.session.bulk_insert_mappings(Proponent, data) + db.session.commit() + return "Created successfully" + + @classmethod + def _read_excel(cls, file: IO) -> pd.DataFrame: + """Read the template excel file""" + column_map = { + "Name": "name", + "Relationship Holder": "relationship_holder_id", + } + data_frame = pd.read_excel(file) + data_frame = data_frame.replace({np.nan: None}) + data_frame.rename(column_map, axis="columns", inplace=True) + return data_frame + + @classmethod + def _find_staff_id(cls, email: str, staffs: List[Staff]) -> int: + """Find and return the id of staff from given list""" + if email is None: + return None + staff = next((x for x in staffs if x.email == email), None) + if staff is None: + raise ResourceNotFoundError(f"Staff with email {email} does not exist") + return staff.id diff --git a/epictrack-api/src/api/services/staff.py b/epictrack-api/src/api/services/staff.py index 64b6a789e..1c5479e16 100644 --- a/epictrack-api/src/api/services/staff.py +++ b/epictrack-api/src/api/services/staff.py @@ -13,11 +13,17 @@ # limitations under the License. """Service to manage Staffs.""" +from typing import IO, List + +import pandas as pd from flask import current_app +from sqlalchemy import text from api.exceptions import ResourceExistsError, ResourceNotFoundError -from api.models import Staff +from api.models import Staff, db +from api.models.position import Position from api.schemas.response import StaffResponseSchema +from api.utils.token_info import TokenInfo class StaffService: @@ -99,3 +105,53 @@ def check_existence(cls, email, staff_id=None): def find_by_email(cls, email): """Find staff by email address""" return Staff.find_by_email(email) + + @classmethod + def import_staffs(cls, file: IO): + """Import proponents""" + data = cls._read_excel(file) + db.session.execute(text("TRUNCATE staffs RESTART IDENTITY CASCADE")) + + position_names = set(data["position_id"].to_list()) + positions = ( + db.session.query(Position) + .filter(Position.name.in_(position_names), Position.is_active.is_(True)) + .all() + ) + + data["position_id"] = data.apply( + lambda x: cls._find_position_id(x["position_id"], positions), axis=1 + ) + + username = TokenInfo.get_username() + data["created_by"] = username + data = data.to_dict("records") + db.session.bulk_insert_mappings(Staff, data) + db.session.commit() + return "Inserted successfully" + + @classmethod + def _read_excel(cls, file: IO) -> pd.DataFrame: + """Read the template excel file""" + column_map = { + "First Name": "first_name", + "Last Name": "last_name", + "Phone": "phone", + "Email": "email", + "Position": "position_id", + } + data_frame = pd.read_excel(file) + data_frame.rename(column_map, axis="columns", inplace=True) + data_frame = data_frame.infer_objects() + data_frame = data_frame.apply(lambda x: x.str.strip() if x.dtype == "object" else x) + return data_frame + + @classmethod + def _find_position_id(cls, name: str, positions: List[Position]) -> int: + """Find and return the id of position from given list""" + if name is None: + return None + position = next((x for x in positions if x.name == name), None) + if position is None: + raise ResourceNotFoundError(f"position with name {name} does not exist") + return position.id diff --git a/epictrack-api/src/api/services/work.py b/epictrack-api/src/api/services/work.py index 9f183556f..d0e1a3b2c 100644 --- a/epictrack-api/src/api/services/work.py +++ b/epictrack-api/src/api/services/work.py @@ -18,26 +18,52 @@ import pandas as pd from flask import current_app -from sqlalchemy import exc, tuple_ +from sqlalchemy import tuple_ from sqlalchemy.orm import aliased -from api.exceptions import ResourceExistsError, ResourceNotFoundError, UnprocessableEntityError +from api.exceptions import ( + ResourceExistsError, + ResourceNotFoundError, + UnprocessableEntityError, +) from api.models import ( - ActionConfiguration, ActionTemplate, CalendarEvent, EAOTeam, Event, EventConfiguration, OutcomeConfiguration, - Project, Role, Staff, StaffWorkRole, Work, WorkCalendarEvent, WorkPhase, WorkStateEnum, db) + ActionConfiguration, + ActionTemplate, + CalendarEvent, + EAOTeam, + Event, + EventConfiguration, + OutcomeConfiguration, + Project, + Role, + Staff, + StaffWorkRole, + Work, + WorkCalendarEvent, + WorkPhase, + WorkStateEnum, + db, +) +from api.models.event_template import EventTemplateVisibilityEnum from api.models.event_category import EventCategoryEnum +from api.models.phase_code import PhaseVisibilityEnum from api.models.indigenous_nation import IndigenousNation from api.models.indigenous_work import IndigenousWork -from api.schemas.request import ActionConfigurationBodyParameterSchema, OutcomeConfigurationBodyParameterSchema +from api.schemas.request import ( + ActionConfigurationBodyParameterSchema, + OutcomeConfigurationBodyParameterSchema, +) from api.schemas.response import ( - ActionTemplateResponseSchema, EventTemplateResponseSchema, OutcomeTemplateResponseSchema) + ActionTemplateResponseSchema, + EventTemplateResponseSchema, + OutcomeTemplateResponseSchema, +) from api.schemas.work_first_nation import WorkFirstNationSchema from api.schemas.work_plan import WorkPlanSchema from api.services.event import EventService from api.services.event_template import EventTemplateService from api.services.outcome_template import OutcomeTemplateService from api.services.phaseservice import PhaseService -from api.utils.datetime_helper import get_start_of_day class WorkService: # pylint: disable=too-many-public-methods @@ -100,58 +126,55 @@ def find_allocated_resources(cls): return works @classmethod - def create_work(cls, payload): + def create_work(cls, payload, commit: bool = True): # pylint: disable=too-many-locals """Create a new work""" - try: - payload["start_date"] = get_start_of_day(payload["start_date"]) - if cls.check_existence(payload["title"]): - raise ResourceExistsError("Work with same title already exists") - work = Work(**payload) - work.work_state = WorkStateEnum.IN_PROGRESS - phases = PhaseService.find_phase_codes_by_ea_act_and_work_type( - work.ea_act_id, work.work_type_id + if cls.check_existence(payload["title"]): + raise ResourceExistsError("Work with same title already exists") + work = Work(**payload) + work.work_state = WorkStateEnum.IN_PROGRESS + phases = PhaseService.find_phase_codes_by_ea_act_and_work_type( + work.ea_act_id, work.work_type_id + ) + if not phases: + raise UnprocessableEntityError("No configuration found") + phase_ids = list(map(lambda x: x.id, phases)) + event_templates = EventTemplateService.find_by_phase_ids(phase_ids) + event_template_json = EventTemplateResponseSchema(many=True).dump( + event_templates + ) + work = work.flush() + phase_start_date = work.start_date + sort_order = 1 + for phase in phases: + end_date = phase_start_date + timedelta(days=phase.number_of_days) + work_phase = { + "work_id": work.id, + "phase_id": phase.id, + "name": phase.name, + "start_date": f"{phase_start_date}", + "end_date": f"{end_date}", + "legislated": phase.legislated, + "number_of_days": phase.number_of_days, + "sort_order": sort_order, + "visibility": phase.visibility, + } + phase_event_templates = list( + filter( + lambda x, _phase_id=phase.id: x["phase_id"] == _phase_id, + event_template_json, + ) ) - if not phases: - raise UnprocessableEntityError("No configuration found") - phase_ids = list(map(lambda x: x.id, phases)) - event_templates = EventTemplateService.find_by_phase_ids(phase_ids) - event_template_json = EventTemplateResponseSchema(many=True).dump( - event_templates + work_phase_id = cls.create_events_by_template( + work_phase, phase_event_templates ) - # TODO: CHANGE TO CURRENT WORK PHASE ID - work = work.flush() - phase_start_date = work.start_date - first_phase = True - for phase in phases: - end_date = phase_start_date + timedelta(days=phase.number_of_days) - work_phase = { - "work_id": work.id, - "phase_id": phase.id, - "name": phase.name, - "start_date": f"{phase_start_date}", - "end_date": f"{end_date}", - "legislated": phase.legislated, - "number_of_days": phase.number_of_days, - } - phase_event_templates = list( - filter( - lambda x, _phase_id=phase.id: x["phase_id"] == _phase_id, - event_template_json, - ) - ) - work_phase_id = cls.handle_phase( - work_phase, phase_event_templates - ) + if phase.visibility != PhaseVisibilityEnum.HIDDEN.value: phase_start_date = end_date + timedelta(days=1) - if first_phase: - work.current_work_phase_id = work_phase_id - first_phase = False - + if sort_order == 1: + work.current_work_phase_id = work_phase_id + sort_order = sort_order + 1 + if commit: db.session.commit() - except exc.IntegrityError as exception: - db.session.rollback() - raise exception return work @classmethod @@ -303,7 +326,7 @@ def _prepare_regular_event( # pylint: disable=too-many-arguments } @classmethod - def _prepare_configuration(cls, data) -> dict: + def _prepare_configuration(cls, data, from_template) -> dict: """Prepare the configuration object""" return { "name": data["name"], @@ -312,12 +335,13 @@ def _prepare_configuration(cls, data) -> dict: "event_category_id": data["event_category_id"], "start_at": data["start_at"], "number_of_days": data["number_of_days"], - "mandatory": data["mandatory"], "event_position": data["event_position"], "multiple_days": data["multiple_days"], "sort_order": data["sort_order"], - "template_id": data["id"], + "template_id": data["id"] if from_template else data["template_id"], "work_phase_id": data["work_phase_id"], + "visibility": data["visibility"], + "repeat_count": 1, } @classmethod @@ -438,7 +462,7 @@ def save_notes(cls, work_id: int, notes_payload: dict) -> Work: """Save notes to the given column in the work.""" # if column name cant map the type in the UI , add it here.. note_type_mapping = { - 'first_nation': 'first_nation_notes', + "first_nation": "first_nation_notes", } work = cls.find_by_id(work_id) @@ -450,7 +474,9 @@ def save_notes(cls, work_id: int, notes_payload: dict) -> Work: else: mapped_column = note_type_mapping.get(note_type) if mapped_column is None: - raise ResourceExistsError(f"No work note type {note_type} nation association found") + raise ResourceExistsError( + f"No work note type {note_type} nation association found" + ) setattr(work, mapped_column, notes) work.save() @@ -613,95 +639,124 @@ def check_work_nation_existence( return False @classmethod - def handle_phase(cls, work_phase, phase_event_templates) -> int: # pylint: disable=too-many-locals + def create_events_by_template( + cls, work_phase: WorkPhase, phase_event_templates: [dict] + ) -> int: # pylint: disable=too-many-locals """Create a new work phase and related events and event configuration entries""" work_phase = WorkPhase.flush(WorkPhase(**work_phase)) - event_configurations = [] - for parent_config in list( - filter(lambda x: not x["parent_id"], phase_event_templates) - ): + event_configurations = cls.create_configurations( + work_phase, phase_event_templates + ) + cls.create_events_by_configuration(work_phase, event_configurations) + return work_phase.id + + @classmethod + def create_configurations( + cls, work_phase: WorkPhase, event_configs: [dict], from_template: bool = True + ) -> [EventConfiguration]: + """Create event configurations from existing configurations/templates""" + event_configurations: [EventConfiguration] = [] + for parent_config in list(filter(lambda x: not x["parent_id"], event_configs)): parent_config["work_phase_id"] = work_phase.id - p_result = EventConfiguration(**cls._prepare_configuration(parent_config)) + p_result = EventConfiguration( + **cls._prepare_configuration(parent_config, from_template) + ) p_result.flush() event_configurations.append(p_result) cls.copy_outcome_and_actions(parent_config, p_result) for child in list( filter( - lambda x, _parent_config_id=parent_config["id"]: x["parent_id"] == _parent_config_id, - phase_event_templates, + lambda x, _parent_config_id=parent_config["id"]: x["parent_id"] + == _parent_config_id, + event_configs, ) ): child["parent_id"] = p_result.id child["work_phase_id"] = work_phase.id c_result = EventConfiguration.flush( - EventConfiguration(**cls._prepare_configuration(child)) + EventConfiguration( + **cls._prepare_configuration(child, from_template) + ) ) event_configurations.append(c_result) cls.copy_outcome_and_actions(child, c_result) - parent_event_configs = list( - filter( - lambda x, _work_phase_id=work_phase.id: not x.parent_id and - x.mandatory and x.work_phase_id == _work_phase_id, - event_configurations, - ) - ) - for p_event_conf in parent_event_configs: - days = cls._find_start_at_value(p_event_conf.start_at, 0) - p_event_start_date = datetime.fromisoformat(work_phase.start_date) + timedelta( - days=days - ) - p_event = Event.flush( - Event( - **cls._prepare_regular_event( - p_event_conf.name, - str(p_event_start_date), - p_event_conf.number_of_days, - p_event_conf.id, - p_event_conf.work_phase.work.id, - ) - ) - ) - c_events = list( + return event_configurations + + @classmethod + def create_events_by_configuration( + cls, work_phase: WorkPhase, event_configurations: [EventConfiguration] + ) -> None: + """Create events by given event configurations""" + if work_phase.visibility == PhaseVisibilityEnum.REGULAR: + parent_event_configs = list( filter( - lambda x, _parent_id=p_event_conf.id, _work_phase_id=work_phase.id: x.parent_id == _parent_id and - x.mandatory and x.work_phase_id == _work_phase_id, # noqa: W503 + lambda x, _work_phase_id=work_phase.id: not x.parent_id + and x.visibility == EventTemplateVisibilityEnum.MANDATORY.value + and x.work_phase_id == _work_phase_id, event_configurations, ) ) - for c_event_conf in c_events: - c_event_start_date = p_event_start_date + timedelta( - days=cls._find_start_at_value(c_event_conf.start_at, 0) - ) - if c_event_conf.event_category_id == EventCategoryEnum.CALENDAR.value: - cal_event = CalendarEvent.flush( - CalendarEvent( - **{ - "name": c_event_conf.name, - "anticipated_date": c_event_start_date, - "number_of_days": c_event_conf.number_of_days, - } + for p_event_conf in parent_event_configs: + days = cls._find_start_at_value(p_event_conf.start_at, 0) + p_event_start_date = datetime.fromisoformat( + work_phase.start_date + ) + timedelta(days=days) + p_event = Event.flush( + Event( + **cls._prepare_regular_event( + p_event_conf.name, + str(p_event_start_date), + p_event_conf.number_of_days, + p_event_conf.id, + p_event_conf.work_phase.work.id, ) ) - WorkCalendarEvent.flush( - WorkCalendarEvent( - **{ - "calendar_event_id": cal_event.id, - "source_event_id": p_event.id, - "event_configuration_id": c_event_conf.id, - } - ) + ) + c_events = list( + filter( + lambda x, _parent_id=p_event_conf.id, _work_phase_id=work_phase.id: x.parent_id + == _parent_id + and x.visibility == EventTemplateVisibilityEnum.MANDATORY.value + and x.work_phase_id == _work_phase_id, # noqa: W503 + event_configurations, ) - else: - Event.flush( - Event( - **cls._prepare_regular_event( - c_event_conf.name, - str(c_event_start_date), - c_event_conf.number_of_days, - c_event_conf.id, - c_event_conf.work_phase.work.id, - p_event.id, + ) + for c_event_conf in c_events: + c_event_start_date = p_event_start_date + timedelta( + days=cls._find_start_at_value(c_event_conf.start_at, 0) + ) + if ( + c_event_conf.event_category_id + == EventCategoryEnum.CALENDAR.value + ): + cal_event = CalendarEvent.flush( + CalendarEvent( + **{ + "name": c_event_conf.name, + "anticipated_date": c_event_start_date, + "number_of_days": c_event_conf.number_of_days, + } + ) + ) + WorkCalendarEvent.flush( + WorkCalendarEvent( + **{ + "calendar_event_id": cal_event.id, + "source_event_id": p_event.id, + "event_configuration_id": c_event_conf.id, + } + ) + ) + else: + Event.flush( + Event( + **cls._prepare_regular_event( + c_event_conf.name, + str(c_event_start_date), + c_event_conf.number_of_days, + c_event_conf.id, + c_event_conf.work_phase.work.id, + p_event.id, + ) ) ) - ) - return work_phase.id diff --git a/epictrack-api/src/api/services/work_phase.py b/epictrack-api/src/api/services/work_phase.py index 895dd92d4..6be755bd0 100644 --- a/epictrack-api/src/api/services/work_phase.py +++ b/epictrack-api/src/api/services/work_phase.py @@ -19,6 +19,7 @@ from api.models import PhaseCode, WorkPhase, db from api.models.event_type import EventTypeEnum from api.schemas.work_v2 import WorkPhaseSchema +from api.models.phase_code import PhaseVisibilityEnum from api.services.event import EventService from api.services.task_template import TaskTemplateService @@ -86,8 +87,9 @@ def find_work_phases_status(cls, work_id: int): WorkPhase.work_id == work_id, WorkPhase.is_active.is_(True), WorkPhase.is_deleted.is_(False), + WorkPhase.visibility != PhaseVisibilityEnum.HIDDEN.value ) - .order_by(WorkPhase.id, PhaseCode.sort_order) + .order_by(WorkPhase.sort_order) .all() ) result = [] diff --git a/epictrack-api/src/api/templates/event_templates/amendment/001_Amendment.xlsx b/epictrack-api/src/api/templates/event_templates/amendment/001_Amendment.xlsx new file mode 100644 index 000000000..7de697dc3 Binary files /dev/null and b/epictrack-api/src/api/templates/event_templates/amendment/001_Amendment.xlsx differ diff --git a/epictrack-api/src/api/templates/event_templates/assessment/001_EAC_Assessment.xlsx b/epictrack-api/src/api/templates/event_templates/assessment/001_EAC_Assessment.xlsx new file mode 100644 index 000000000..e33ee991a Binary files /dev/null and b/epictrack-api/src/api/templates/event_templates/assessment/001_EAC_Assessment.xlsx differ diff --git a/epictrack-api/src/api/templates/event_templates/ceao_designation/001-CEAO_Designation.xlsx b/epictrack-api/src/api/templates/event_templates/ceao_designation/001-CEAO_Designation.xlsx index 420267744..43080b0f3 100644 Binary files a/epictrack-api/src/api/templates/event_templates/ceao_designation/001-CEAO_Designation.xlsx and b/epictrack-api/src/api/templates/event_templates/ceao_designation/001-CEAO_Designation.xlsx differ diff --git a/epictrack-api/src/api/templates/event_templates/exemption_request/001_Exemption_Request.xlsx b/epictrack-api/src/api/templates/event_templates/exemption_request/001_Exemption_Request.xlsx new file mode 100644 index 000000000..4df0086f9 Binary files /dev/null and b/epictrack-api/src/api/templates/event_templates/exemption_request/001_Exemption_Request.xlsx differ diff --git a/epictrack-api/src/api/templates/event_templates/minister_designation/001-Minister_Designation.xlsx b/epictrack-api/src/api/templates/event_templates/minister_designation/001-Minister_Designation.xlsx index ec4c2a512..498d9e0e5 100644 Binary files a/epictrack-api/src/api/templates/event_templates/minister_designation/001-Minister_Designation.xlsx and b/epictrack-api/src/api/templates/event_templates/minister_designation/001-Minister_Designation.xlsx differ diff --git a/epictrack-api/src/api/templates/event_templates/project_notification/001-Project_Notification.xlsx b/epictrack-api/src/api/templates/event_templates/project_notification/001-Project_Notification.xlsx index c158bd50f..30e25d3a0 100644 Binary files a/epictrack-api/src/api/templates/event_templates/project_notification/001-Project_Notification.xlsx and b/epictrack-api/src/api/templates/event_templates/project_notification/001-Project_Notification.xlsx differ diff --git a/epictrack-api/src/api/templates/task_template.xlsx b/epictrack-api/src/api/templates/task_template/task_template.xlsx similarity index 100% rename from epictrack-api/src/api/templates/task_template.xlsx rename to epictrack-api/src/api/templates/task_template/task_template.xlsx diff --git a/epictrack-api/src/api/utils/constants.py b/epictrack-api/src/api/utils/constants.py index 269c2e447..4b82d16f4 100644 --- a/epictrack-api/src/api/utils/constants.py +++ b/epictrack-api/src/api/utils/constants.py @@ -1,5 +1,8 @@ """File representing constants used in the application""" +from api.models.project import ProjectStateEnum + + SCHEMA_MAPS = { "work": "api.schemas.work.WorksFormSchema", "_": "api.schemas.default.DefaultSchema" @@ -9,3 +12,18 @@ CACHE_DAY_TIMEOUT = 84600 CACHE_TYPE = 'SimpleCache' NULL_CACHE_TYPE = 'NullCache' + + +PROJECT_STATE_ENUM_MAPS = { + "Active: Unknown": ProjectStateEnum.UNKNOWN.value, + "Active: Operation": ProjectStateEnum.OPERATION.value, + "CLOSED": ProjectStateEnum.CLOSED.value, + "Active: Under EAC Assessment": ProjectStateEnum.UNDER_EAC_ASSESSMENT.value, + "Active: Care & Maintenance": ProjectStateEnum.CARE_AND_MAINTENANCE.value, + "Active: Under Designation": ProjectStateEnum.UNDER_DESIGNATION.value, + "Active: Pre-Construction": ProjectStateEnum.PRE_CONSTRUCTION.value, + "Active: Construction": ProjectStateEnum.CONSTRUCTION.value, + "Active: Decommission": ProjectStateEnum.DECOMMISSION.value, +} + +PIP_LINK_URL_BASE = "https://apps.nrs.gov.bc.ca/int/fnp/FirstNationDetail.xhtml?name=" diff --git a/epictrack-web/src/components/workPlan/event/EventForm.tsx b/epictrack-web/src/components/workPlan/event/EventForm.tsx index fd49f31b4..fd7a81b1a 100644 --- a/epictrack-web/src/components/workPlan/event/EventForm.tsx +++ b/epictrack-web/src/components/workPlan/event/EventForm.tsx @@ -272,7 +272,7 @@ const EventForm = ({ try { const result = await configurationService.getAll( Number(ctx.selectedWorkPhase?.work_phase.id), - event === undefined ? false : undefined + event === undefined ? false : true ); if (result.status === 200) { setConfigurations(result.data as any[]); diff --git a/epictrack-web/src/components/workPlan/event/EventList.tsx b/epictrack-web/src/components/workPlan/event/EventList.tsx index 89c4b01f0..04b6f9b45 100644 --- a/epictrack-web/src/components/workPlan/event/EventList.tsx +++ b/epictrack-web/src/components/workPlan/event/EventList.tsx @@ -2,7 +2,11 @@ import React, { useContext, useEffect } from "react"; import { EVENT_TYPE } from "../phase/type"; import eventService from "../../../services/eventService/eventService"; import Icons from "../../icons"; -import { EventsGridModel, MilestoneEvent } from "../../../models/event"; +import { + EventTemplateVisibility, + EventsGridModel, + MilestoneEvent, +} from "../../../models/event"; import Moment from "moment"; import { WorkplanContext } from "../WorkPlanContext"; import { MRT_RowSelectionState } from "material-react-table"; @@ -203,7 +207,7 @@ const EventList = () => { : actualToTodayDiff <= 0 ? EVENT_STATUS.INPROGRESS : EVENT_STATUS.NOT_STARTED; - element.mandatory = element.event_configuration.mandatory; + element.visibility = element.event_configuration.visibility; return element; }); } @@ -308,7 +312,8 @@ const EventList = () => { setShowTaskForm(row.type === EVENT_TYPE.TASK); } setShowDeleteMilestoneButton( - row.type === EVENT_TYPE.MILESTONE && !row.mandatory + row.type === EVENT_TYPE.MILESTONE && + !(row.visibility === EventTemplateVisibility.MANDATORY) ); }; const onCancelHandler = () => { diff --git a/epictrack-web/src/models/event.ts b/epictrack-web/src/models/event.ts index a47702e91..0ad71efdb 100644 --- a/epictrack-web/src/models/event.ts +++ b/epictrack-web/src/models/event.ts @@ -22,7 +22,7 @@ export interface EventsGridModel { responsibility: string; notes: string; status: EVENT_STATUS; - mandatory: boolean; + visibility: EventTemplateVisibility; } export interface MilestoneEvent { @@ -80,3 +80,9 @@ export enum EventPosition { INTERMEDIATE = "INTERMEDIATE", END = "END", } + +export enum EventTemplateVisibility { + MANDATORY = "MANDATORY", + OPTIONAL = "OPTIONAL", + HIDDEN = "HIDDEN", +} diff --git a/epictrack-web/src/models/eventConfiguration.ts b/epictrack-web/src/models/eventConfiguration.ts index 9f0f089a0..ce6e4c81b 100644 --- a/epictrack-web/src/models/eventConfiguration.ts +++ b/epictrack-web/src/models/eventConfiguration.ts @@ -1,4 +1,4 @@ -import { EventPosition } from "./event"; +import { EventPosition, EventTemplateVisibility } from "./event"; export default interface EventConfiguration { id: number; @@ -7,6 +7,6 @@ export default interface EventConfiguration { event_type_id: number; multiple_days: boolean; event_position: EventPosition; - mandatory: boolean; + visibilty_mode: EventTemplateVisibility; work_phase_id: number; }