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

DESENG-689: Add image widget #2586

Merged
merged 4 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading