Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/deseng439: Created Timline Widget and merged with Content from Main #2347

Merged
merged 11 commits into from
Jan 9, 2024
Merged
6 changes: 5 additions & 1 deletion CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Change Log

All notable changes to this project will be documented in this file.
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/).
jareth-whitney marked this conversation as resolved.
Show resolved Hide resolved

## December 28, 2023

> **Feature**: Added the timeline widget. [🎟️DESENG-439](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-439)

## December 11, 2023

Expand Down
2 changes: 1 addition & 1 deletion analytics-api/src/analytics_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
load_dotenv(find_dotenv())


def get_named_config(environment: str | None) -> '_Config':
def get_named_config(environment: 'str | None') -> '_Config':
"""
Retrieve a configuration object by name. Used by the Flask app factory.

Expand Down
27 changes: 27 additions & 0 deletions docs/MET_database_ERD.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,33 @@ erDiagram
string updated_by
}
widget only one to zero or more widget_video : has
widget_timeline {
integer id PK
integer widget_id FK "The id from widget"
integer engagement_id FK "The id from engagement"
string title
string description
timestamp created_date
timestamp updated_date
string created_by
string updated_by
}
widget only one to zero or more widget_timeline : has
timeline_event {
integer id PK
integer widget_id FK "The id from widget"
integer engagement_id FK "The id from engagement"
integer timeline_id FK "The id from timeline"
string description
string time
enum status
integer position
timestamp created_date
timestamp updated_date
string created_by
string updated_by
}
widget only one to zero or more timeline_event : has
widget_documents {
integer id PK
string title
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Added the Timeline Widget.

Revision ID: 3e4dc76a96ab
Revises: 02ff8ecc6b91
Create Date: 2023-12-05 17:04:46.304368

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from met_api.constants.timeline_event_status import TimelineEventStatus

# revision identifiers, used by Alembic.
revision = '3e4dc76a96ab'
down_revision = '02ff8ecc6b91'
branch_labels = None
depends_on = None


def upgrade():
op.create_table('widget_timeline',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column('engagement_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_table('timeline_event',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column('engagement_id', sa.Integer(), nullable=False),
sa.Column('timeline_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum(TimelineEventStatus), nullable=True),
sa.Column('position', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('time', sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['timeline_id'], ['widget_timeline.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)

def downgrade():
op.drop_table('widget_timeline')
op.drop_table('timeline_event')
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Add widget_id to widget_timeline and timeline_event tables

Revision ID: 4114001e1a4c
Revises: c09e77fde608
Create Date: 2023-12-11 15:46:30.773046

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = '4114001e1a4c'
down_revision = 'c09e77fde608'
branch_labels = None
depends_on = None


def upgrade():
op.add_column('widget_timeline', sa.Column('widget_id', sa.Integer()))
op.create_foreign_key('timeline_widget_fk', 'widget_timeline', 'widget', ['widget_id'], ['id'], ondelete='CASCADE')
op.add_column('timeline_event', sa.Column('widget_id', sa.Integer()))
op.create_foreign_key('event_widget_fk', 'timeline_event', 'widget', ['widget_id'], ['id'], ondelete='CASCADE')

def downgrade():
op.drop_column('widget_timeline', 'widget_id')
op.drop_column('timeline_event', 'widget_id')
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Added enum value for Timeline Widget.

Revision ID: c09e77fde608
Revises: 3e4dc76a96ab
Create Date: 2023-12-06 11:46:20.934373

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = 'c09e77fde608'
down_revision = '3e4dc76a96ab'
branch_labels = None
depends_on = None


def upgrade():
op.add_column('timeline_event', sa.Column('created_date', sa.DateTime(), nullable=False))
op.add_column('timeline_event', sa.Column('updated_date', sa.DateTime(), nullable=True))
op.add_column('timeline_event', sa.Column('created_by', sa.String(length=50), nullable=True))
op.add_column('timeline_event', sa.Column('updated_by', sa.String(length=50), nullable=True))

op.add_column('widget_timeline', sa.Column('created_date', sa.DateTime(), nullable=False))
op.add_column('widget_timeline', sa.Column('updated_date', sa.DateTime(), nullable=True))
op.add_column('widget_timeline', sa.Column('created_by', sa.String(length=50), nullable=True))
op.add_column('widget_timeline', sa.Column('updated_by', sa.String(length=50), nullable=True))

widget_type_table = sa.table('widget_type',
sa.Column('id', sa.Integer),
sa.Column('name', sa.String),
sa.Column('description', sa.String))

op.bulk_insert(
widget_type_table,
[
{
'id': 8,
'name': 'CAC Form',
'description': 'Add a CAC Form to your project',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine this example text should be changed? CAC forms are unrelated to timelines, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, in met-web, for the widget type ENUM, value 8 is reserved for CAC forms. Timeline is 9. When I looked in the DB, CAC Form was missing as a widget type, so I added it as number 8 and added Timeline as number 9. I thought it would be better to leave that path open, rather than remove all instances of CAC Form.

},
{
'id': 9,
'name': 'Timeline',
'description': 'Create a timeline for a series of events',
},
]
)

def downgrade():
op.drop_column('widget_timeline', 'updated_by')
op.drop_column('widget_timeline', 'created_by')
op.drop_column('widget_timeline', 'updated_date')
op.drop_column('widget_timeline', 'created_date')
op.drop_column('timeline_event', 'updated_by')
op.drop_column('timeline_event', 'created_by')
op.drop_column('timeline_event', 'updated_date')
op.drop_column('timeline_event', 'created_date')
op.delete(widget_type_table).filter_by(id=8)
op.delete(widget_type_table).filter_by(id=9)
2 changes: 1 addition & 1 deletion met-api/src/met_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
os.environ = {k: v for k, v in os.environ.items() if v}


def get_named_config(environment: str | None) -> 'Config':
def get_named_config(environment: 'str | None') -> 'Config':
"""
Retrieve a configuration object by name. Used by the Flask app factory.

Expand Down
10 changes: 10 additions & 0 deletions met-api/src/met_api/constants/timeline_event_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Constants of timeline events."""
from enum import IntEnum


class TimelineEventStatus(IntEnum):
"""Enum of timeline event status status."""

Pending = 1
InProgress = 2
Completed = 3
2 changes: 2 additions & 0 deletions met-api/src/met_api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,5 @@
from .report_setting import ReportSetting
from .widget_video import WidgetVideo
from .cac_form import CACForm
from .widget_timeline import WidgetTimeline
from .timeline_event import TimelineEvent
51 changes: 51 additions & 0 deletions met-api/src/met_api/models/timeline_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Timeline Event model class.

Manages the timeline events
"""
from __future__ import annotations

from sqlalchemy.sql.schema import ForeignKey
from met_api.constants.timeline_event_status import TimelineEventStatus

from .base_model import BaseModel
from .db import db


class TimelineEvent(BaseModel):
"""Definition of the TimelineEvent entity."""

__tablename__ = 'timeline_event'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=True)
widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=True)
timeline_id = db.Column(db.Integer, ForeignKey('widget_timeline.id', ondelete='CASCADE'), nullable=True)
status = db.Column(db.Enum(TimelineEventStatus), nullable=False)
position = db.Column(db.Integer, nullable=False)
description = db.Column(db.Text(), nullable=True)
time = db.Column(db.String(255), nullable=True)

@classmethod
def delete_event(cls, timeline_id):
"""Delete timeline."""
timeline_event = db.session.query(TimelineEvent) \
.filter(TimelineEvent.timeline_id == timeline_id)
timeline_event.delete()
db.session.commit()

@classmethod
def get_timeline_events(cls, timeline_id) -> list[TimelineEvent]:
"""Get timeline event."""
timeline_event = db.session.query(TimelineEvent) \
.filter(TimelineEvent.timeline_id == timeline_id) \
.all()
return timeline_event

@classmethod
def update_timeline_event(cls, timeline_id, event_data: dict) -> TimelineEvent:
"""Update timeline event."""
timeline_event: TimelineEvent = TimelineEvent.query.get(timeline_id)
if timeline_event:
for key, value in event_data.items():
setattr(timeline_event, key, value)
timeline_event.save()
return timeline_event
45 changes: 45 additions & 0 deletions met-api/src/met_api/models/widget_timeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""WidgetTimeline model class.

Manages the timeline widget
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy.sql.schema import ForeignKey
from met_api.models.timeline_event import TimelineEvent
from met_api.services.timeline_event_service import TimelineEventService
from .base_model import BaseModel
from .db import db

class WidgetTimeline(BaseModel): # pylint: disable=too-few-public-methods, too-many-instance-attributes
"""Definition of the Timeline entity."""

__tablename__ = 'widget_timeline'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=True)
widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=True)
title = db.Column(db.String(255), nullable=True)
description = db.Column(db.Text(), nullable=True)

# Relationship to timeline_event
events = db.relationship(TimelineEvent, backref='widget_timeline', lazy=True)

@classmethod
def get_timeline(cls, timeline_id) -> list[WidgetTimeline]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be a pattern throughout the app of models only containing some CRUD methods. In looking through the models, some have all 4 methods (GET, PATCH, POST, DELETE) and sometimes half of those are handled in the Service class (including the corresponding DB operations).

I don't understand why that pattern is in place but I feel all CRUD operations should live inside the models. Was there a reason that you didn't include the POST operation besides just copying from another file? I remember you mentioned the DELETE operation is handled by the base Widget class, so I understand that.

I know we have pressure on us to get features done but I also feel the PO would understand us taking more time to make things more consistent and better in the code base.

Copy link
Contributor Author

@jareth-whitney jareth-whitney Jan 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So anything that touches the db is defined in the model. Then the service just uses those methods from the model to do stuff. I was confused at first too, and so was Nat, but after looking at the logic, I now understand it. Sometimes a service method doesn't need to call a model method that touches the db, so nothing is defined in the model.

"""Get timeline."""
widget_timeline = db.session.query(WidgetTimeline) \
.filter(WidgetTimeline.widget_id == timeline_id) \
.all()
return widget_timeline

@classmethod
def update_timeline(cls, timeline_id, timeline_data: dict) -> Optional[WidgetTimeline or None]:
"""Update timeline."""
TimelineEvent.delete_event(timeline_id)
widget_timeline: WidgetTimeline = WidgetTimeline.query.get(timeline_id)
if widget_timeline:
widget_timeline.title = timeline_data.get('title')
widget_timeline.description = timeline_data.get('description')
for event in timeline_data.get('events', []):
TimelineEventService.create_timeline_event(timeline_id, event)
widget_timeline.save()
return widget_timeline
2 changes: 2 additions & 0 deletions met-api/src/met_api/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from .widget_video import API as WIDGET_VIDEO_API
from .engagement_settings import API as ENGAGEMENT_SETTINGS_API
from .cac_form import API as CAC_FORM_API
from .widget_timeline import API as WIDGET_TIMELINE_API

__all__ = ('API_BLUEPRINT',)

Expand Down Expand Up @@ -89,3 +90,4 @@
API.add_namespace(WIDGET_VIDEO_API, path='/widgets/<int:widget_id>/videos')
API.add_namespace(ENGAGEMENT_SETTINGS_API)
API.add_namespace(CAC_FORM_API, path='/engagements/<int:engagement_id>/cacform')
API.add_namespace(WIDGET_TIMELINE_API, path='/widgets/<int:widget_id>/timelines')
Loading
Loading