Skip to content

Commit

Permalink
Merge pull request #2586 from bcgov/feature/DESENG-689-add-image-widget
Browse files Browse the repository at this point in the history
DESENG-689: Add image widget
  • Loading branch information
NatSquared authored Sep 10, 2024
2 parents 9e5a4f8 + ae7b6b9 commit 6ba4401
Show file tree
Hide file tree
Showing 30 changed files with 1,121 additions and 149 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
## September 9, 2024

- **Feature** Add image widget [🎟️ DESENG-689](https://citz-gdx.atlassian.net/browse/DESENG-689)
- Added a new "ImageWidget" widget type in the API
- Image widgets can have a title, optional description, uploaded image, and optional alt text
- Added image widget option to the engagement authoring wizard
- Added image widget display for the engagement view page

## September 3, 2024

- **Feature** New authoring content section [🎟️ DESENG-668](https://citz-gdx.atlassian.net/browse/DESENG-668)
- Implemented authoring side nav
- Implemented authoring bottom nav
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Add new Image type to widget type table
Revision ID: e706db763790
Revises: 42641011576a
Create Date: 2024-09-04 14:03:57.967946
"""

from datetime import datetime, UTC
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
from sqlalchemy import String, Integer, DateTime


# revision identifiers, used by Alembic.
revision = "e706db763790"
down_revision = "42641011576a"
branch_labels = None
depends_on = None


def upgrade():
# Temporary table model for existing widget_type table
widget_type_table = table(
"widget_type",
column("id", Integer),
column("name", String),
column("description", String),
column("created_date", DateTime),
column("updated_date", DateTime),
column("created_by", String),
column("updated_by", String),
)
# Insert new widget type
op.bulk_insert(
widget_type_table,
[
{
"id": 11,
"name": "Image",
"description": "Displays a static image, with optional caption",
"created_by": "migration",
"updated_by": "migration",
"created_date": datetime.now(UTC),
"updated_date": datetime.now(UTC),
}
],
)
op.create_table(
"widget_image",
sa.Column("created_date", sa.DateTime(), nullable=False),
sa.Column("updated_date", sa.DateTime(), nullable=True),
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("widget_id", sa.Integer(), nullable=True),
sa.Column("engagement_id", sa.Integer(), nullable=True),
sa.Column("image_url", sa.String(length=255), nullable=False),
sa.Column("alt_text", sa.String(length=255), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_by", sa.String(length=50), nullable=True),
sa.Column("updated_by", sa.String(length=50), nullable=True),
sa.ForeignKeyConstraint(
["engagement_id"], ["engagement.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["widget_id"], ["widget.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("widget_image")
op.execute("DELETE FROM widget_type WHERE id = 11")
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions met-api/src/met_api/constants/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ class WidgetType(IntEnum):
Video = 7
Timeline = 9
Poll = 10
Image = 11
1 change: 1 addition & 0 deletions met-api/src/met_api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from .engagement_slug import EngagementSlug
from .report_setting import ReportSetting
from .widget_video import WidgetVideo
from .widget_image import WidgetImage
from .cac_form import CACForm
from .engagement_metadata import EngagementMetadata, MetadataTaxon
from .widget_timeline import WidgetTimeline
Expand Down
43 changes: 43 additions & 0 deletions met-api/src/met_api/models/widget_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""WidgetImage model class.
Manages the image widget
"""

from __future__ import annotations

from sqlalchemy.sql.schema import ForeignKey

from .base_model import BaseModel
from .db import db


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

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

@classmethod
def get_image(cls, widget_id) -> list[WidgetImage]:
"""Get an image by widget_id."""
return WidgetImage.query.filter(WidgetImage.widget_id == widget_id).all()

@classmethod
def update_image(cls, widget_id, widget_data) -> WidgetImage:
"""Update an image by widget_id."""
image = WidgetImage.get_image(widget_id)[0]
for key, value in widget_data.items():
setattr(image, key, value)
image.save()
return image
6 changes: 5 additions & 1 deletion met-api/src/met_api/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from .cac_form import API as CAC_FORM_API
from .widget_timeline import API as WIDGET_TIMELINE_API
from .widget_poll import API as WIDGET_POLL_API
from .widget_image import API as WIDGET_IMAGE_API
from .language import API as LANGUAGE_API
from .widget_translation import API as WIDGET_TRANSLATION_API
from .survey_translation import API as SURVEY_TRANSLATION_API
Expand All @@ -69,7 +70,9 @@
URL_PREFIX = '/api/'
API_BLUEPRINT = Blueprint('API', __name__, url_prefix=URL_PREFIX)

API = Api(API_BLUEPRINT, title='MET API', version='1.0', description='The Core API for MET')
API = Api(
API_BLUEPRINT, title='MET API', version='1.0', description='The Core API for MET'
)

# HANDLER = ExceptionHandler(API)

Expand Down Expand Up @@ -102,6 +105,7 @@
API.add_namespace(CAC_FORM_API, path='/engagements/<int:engagement_id>/cacform')
API.add_namespace(WIDGET_TIMELINE_API, path='/widgets/<int:widget_id>/timelines')
API.add_namespace(WIDGET_POLL_API, path='/widgets/<int:widget_id>/polls')
API.add_namespace(WIDGET_IMAGE_API, path='/widgets/<int:widget_id>/images')
API.add_namespace(LANGUAGE_API, path='/languages')
API.add_namespace(WIDGET_TRANSLATION_API, path='/widget/<int:widget_id>/translations')
API.add_namespace(SURVEY_TRANSLATION_API, path='/surveys/<int:survey_id>/translations')
Expand Down
102 changes: 102 additions & 0 deletions met-api/src/met_api/resources/widget_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Copyright © 2021 Province of British Columbia
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""API endpoints for managing a image widget resource."""
from http import HTTPStatus

from flask import request
from flask_cors import cross_origin
from flask_restx import Namespace, Resource, fields

from met_api.auth import jwt as _jwt
from met_api.exceptions.business_exception import BusinessException
from met_api.schemas.widget_image import WidgetImageSchema
from met_api.services.widget_image_service import WidgetImageService
from met_api.utils.util import allowedorigins, cors_preflight


API = Namespace('widget_images', description='Endpoints for Image Widget Management')

# Do not allow updating the widget_id or engagement_id via API calls

image_creation_model = API.model(
'ImageCreation',
{
'image_url': fields.String(description='The URL of the image', required=True),
'alt_text': fields.String(description='The alt text for the image'),
'description': fields.String(description='The description of the image'),
},
)

image_update_model = API.model(
'ImageUpdate',
{
'image_url': fields.String(description='The URL of the image'),
'alt_text': fields.String(description='The alt text for the image'),
'description': fields.String(description='The description of the image'),
},
)


@cors_preflight('GET, POST, PATCH, OPTIONS')
@API.route('')
class Images(Resource):
"""Resource for managing image widgets."""

@staticmethod
@cross_origin(origins=allowedorigins())
def get(widget_id):
"""Get image widget."""
try:
widget_image = WidgetImageService().get_image(widget_id)
return (
WidgetImageSchema().dump(widget_image, many=True),
HTTPStatus.OK,
)
except BusinessException as err:
return str(err), err.status_code

@staticmethod
@cross_origin(origins=allowedorigins())
@_jwt.requires_auth
@API.expect(image_creation_model, validate=True)
def post(widget_id):
"""Create image widget."""
try:
request_json = request.get_json()
widget_image = WidgetImageService().create_image(widget_id, request_json)
return WidgetImageSchema().dump(widget_image), HTTPStatus.OK
except BusinessException as err:
return str(err), err.status_code


@cors_preflight('PATCH')
@API.route('/<int:image_widget_id>')
class Image(Resource):
"""Resource for managing specific image widget instances by ID."""

@staticmethod
@cross_origin(origins=allowedorigins())
@_jwt.requires_auth
@API.expect(image_update_model, validate=True)
def patch(widget_id, image_widget_id):
"""Update image widget."""
request_json = request.get_json()
try:
WidgetImageSchema().load(request_json)
widget_image = WidgetImageService().update_image(
widget_id, image_widget_id, request_json
)
return WidgetImageSchema().dump(widget_image), HTTPStatus.OK
except BusinessException as err:
return str(err), err.status_code
35 changes: 35 additions & 0 deletions met-api/src/met_api/schemas/widget_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright © 2019 Province of British Columbia
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Widget image schema definition."""

from met_api.models.widget_image import WidgetImage as WidgetImageModel

from marshmallow import Schema


class WidgetImageSchema(Schema):
"""This is the schema for the image model."""

class Meta: # pylint: disable=too-few-public-methods
"""Images all of the Widget Image fields to a default schema."""

model = WidgetImageModel
fields = (
'id',
'widget_id',
'engagement_id',
'image_url',
'alt_text',
'description',
)
58 changes: 58 additions & 0 deletions met-api/src/met_api/services/widget_image_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Service for Widget Image management."""

from met_api.constants.membership_type import MembershipType
from met_api.models.widget_image import WidgetImage as WidgetImageModel
from met_api.services import authorization
from met_api.utils.roles import Role


class WidgetImageService:
"""Widget image management service."""

@staticmethod
def get_image(widget_id):
"""Get image by widget id."""
widget_image = WidgetImageModel.get_image(widget_id)
return widget_image

@staticmethod
def create_image(widget_id, image_details: dict):
"""Create image for the widget."""
image_data = dict(image_details)
eng_id = image_data.get('engagement_id')
authorization.check_auth(
one_of_roles=(MembershipType.TEAM_MEMBER.name, Role.EDIT_ENGAGEMENT.value),
engagement_id=eng_id,
)

widget_image = WidgetImageService._create_image_model(widget_id, image_data)
widget_image.commit()
return widget_image

@staticmethod
def update_image(widget_id, image_widget_id, image_data):
"""Update image widget."""
widget_image: WidgetImageModel = WidgetImageModel.find_by_id(image_widget_id)
authorization.check_auth(
one_of_roles=(MembershipType.TEAM_MEMBER.name, Role.EDIT_ENGAGEMENT.value),
engagement_id=widget_image.engagement_id,
)

if not widget_image:
raise KeyError('image widget not found')

if widget_image.widget_id != widget_id:
raise ValueError('Invalid widgets and image')

return WidgetImageModel.update_image(widget_id, image_data)

@staticmethod
def _create_image_model(widget_id, image_data: dict):
image_model: WidgetImageModel = WidgetImageModel()
image_model.widget_id = widget_id
image_model.engagement_id = image_data.get('engagement_id')
image_model.image_url = image_data.get('image_url')
image_model.description = image_data.get('description')
image_model.alt_text = image_data.get('alt_text')
image_model.flush()
return image_model
7 changes: 7 additions & 0 deletions met-api/tests/utilities/factory_scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,13 @@ class TestWidgetInfo(dict, Enum):
'created_date': datetime.now().strftime('%Y-%m-%d'),
'updated_date': datetime.now().strftime('%Y-%m-%d'),
}
widget_image = {
'widget_type_id': WidgetType.Image.value,
'created_by': '123',
'updated_by': '123',
'created_date': datetime.now().strftime('%Y-%m-%d'),
'updated_date': datetime.now().strftime('%Y-%m-%d'),
}


class TestWidgetItemInfo(dict, Enum):
Expand Down
5 changes: 5 additions & 0 deletions met-web/src/apiManager/endpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ const Endpoints = {
CREATE: `${AppConfig.apiUrl}/widgets/widget_id/timelines`,
UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/timelines/timeline_id`,
},
ImageWidgets: {
GET: `${AppConfig.apiUrl}/widgets/widget_id/images`,
CREATE: `${AppConfig.apiUrl}/widgets/widget_id/images`,
UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/images/image_widget_id`,
},
Tenants: {
CREATE: `${AppConfig.apiUrl}/tenants/`,
GET: `${AppConfig.apiUrl}/tenants/tenant_id`,
Expand Down
Loading

0 comments on commit 6ba4401

Please sign in to comment.