Skip to content

Commit 4674f0a

Browse files
committed
DEP-265: Remove engagement slug table; merge into engagements
1 parent 5ae1815 commit 4674f0a

43 files changed

Lines changed: 605 additions & 996 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Move slug from separate table to engagement table
2+
3+
Revision ID: e572f7d709c5
4+
Revises: e1f7a3c9b2d5
5+
Create Date: 2026-06-15 14:06:46.186253
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
from sqlalchemy.dialects import postgresql
11+
12+
# revision identifiers, used by Alembic.
13+
revision = 'e572f7d709c5'
14+
down_revision = 'e1f7a3c9b2d5'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.drop_index('idx_slug', table_name='engagement_slug')
22+
op.add_column('engagement', sa.Column(
23+
'slug', sa.String(length=256), nullable=True))
24+
# Migrate data from engagement_slug to engagement
25+
op.execute("""
26+
UPDATE engagement e
27+
SET slug = es.slug
28+
FROM engagement_slug es
29+
WHERE e.id = es.engagement_id
30+
""")
31+
# If there are any engagements without a slug, auto-generate from the name
32+
op.execute("""
33+
UPDATE engagement
34+
SET slug = regexp_replace(lower(name), '\\s+', '-', 'g')
35+
WHERE slug IS NULL
36+
""")
37+
op.alter_column('engagement', 'slug',
38+
existing_type=sa.String(length=256),
39+
nullable=False)
40+
op.create_unique_constraint(
41+
'uq_slug_per_tenant', 'engagement', ['slug', 'tenant_id'])
42+
op.drop_table('engagement_slug')
43+
# ### end Alembic commands ###
44+
45+
46+
def downgrade():
47+
# ### commands auto generated by Alembic - please adjust! ###
48+
op.drop_constraint('uq_slug_per_tenant', 'engagement', type_='unique')
49+
op.drop_column('engagement', 'slug')
50+
op.create_table('engagement_slug',
51+
sa.Column('created_date', postgresql.TIMESTAMP(),
52+
autoincrement=False, nullable=False),
53+
sa.Column('updated_date', postgresql.TIMESTAMP(),
54+
autoincrement=False, nullable=True),
55+
sa.Column('id', sa.INTEGER(),
56+
autoincrement=True, nullable=False),
57+
sa.Column('engagement_id', sa.INTEGER(),
58+
autoincrement=False, nullable=False),
59+
sa.Column('slug', sa.VARCHAR(length=200),
60+
autoincrement=False, nullable=False),
61+
sa.Column('created_by', sa.VARCHAR(length=50),
62+
autoincrement=False, nullable=True),
63+
sa.Column('updated_by', sa.VARCHAR(length=50),
64+
autoincrement=False, nullable=True),
65+
sa.ForeignKeyConstraint(['engagement_id'], [
66+
'engagement.id'], name='engagement_slug_engagement_id_fkey', ondelete='CASCADE'),
67+
sa.PrimaryKeyConstraint('id', name='engagement_slug_pkey'),
68+
sa.UniqueConstraint(
69+
'engagement_id', name='engagement_slug_engagement_id_key'),
70+
sa.UniqueConstraint(
71+
'slug', name='engagement_slug_slug_key')
72+
)
73+
op.create_index('idx_slug', 'engagement_slug', ['slug'], unique=False)
74+
# ### end Alembic commands ###

api/src/api/models/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
from .widget_item import WidgetItem
4646
from .widget_type import WidgetType
4747
from .email_queue import EmailQueue
48-
from .engagement_slug import EngagementSlug
4948
from .report_setting import ReportSetting
5049
from .widget_video import WidgetVideo
5150
from .widget_listening import WidgetListening

api/src/api/models/engagement.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class Engagement(BaseModel):
4848
__tablename__ = 'engagement'
4949
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
5050
name = db.Column(db.String(50))
51+
slug = db.Column(db.String(256), nullable=False)
5152
start_date = db.Column(db.DateTime)
5253
end_date = db.Column(db.DateTime)
5354
status_id = db.Column(db.Integer, ForeignKey(
@@ -98,6 +99,10 @@ class Engagement(BaseModel):
9899
suggested_engagement_ids = association_proxy(
99100
'suggested_engagement_links', 'suggested_engagement_id')
100101

102+
__table_args__ = (db.UniqueConstraint(
103+
'slug', 'tenant_id', name='uq_slug_per_tenant'),
104+
)
105+
101106
@classmethod
102107
def get_engagements_paginated(
103108
cls,

api/src/api/models/engagement_slug.py

Lines changed: 0 additions & 59 deletions
This file was deleted.

api/src/api/resources/__init__.py

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@
4646
from .widget_map import API as WIDGET_MAPS_API
4747
from .shape_file import API as SHAPEFILE_API
4848
from .tenant import API as TENANT_API
49-
from .engagement_slug import API as ENGAGEMENT_SLUG_API
5049
from .report_setting import API as REPORT_SETTING_API
5150
from .widget_video import API as WIDGET_VIDEO_API
5251
from .engagement_settings import API as ENGAGEMENT_SETTINGS_API
@@ -85,35 +84,49 @@
8584
API.add_namespace(SUBSCRIPTION_API)
8685
API.add_namespace(COMMENT_API)
8786
API.add_namespace(EMAIL_VERIFICATION_API)
88-
API.add_namespace(ENGAGEMENT_DETAILS_TAB_API, path='/engagement/<int:engagement_id>/details')
87+
API.add_namespace(ENGAGEMENT_DETAILS_TAB_API,
88+
path='/engagement/<int:engagement_id>/details')
8989
API.add_namespace(FEEDBACK_API)
9090
API.add_namespace(WIDGET_API)
9191
API.add_namespace(CONTACT_API)
9292
API.add_namespace(VALUE_COMPONENTS_API)
9393
API.add_namespace(SHAPEFILE_API)
9494
API.add_namespace(TENANT_API)
9595
API.add_namespace(METADATA_TAXON_API, path='/engagement_metadata')
96-
API.add_namespace(ENGAGEMENT_METADATA_API, path='/engagements/<int:engagement_id>/metadata')
97-
API.add_namespace(ENGAGEMENT_MEMBERS_API, path='/engagements/<string:engagement_id>/members')
98-
API.add_namespace(WIDGET_DOCUMENTS_API, path='/widgets/<string:widget_id>/documents')
96+
API.add_namespace(ENGAGEMENT_METADATA_API,
97+
path='/engagements/<int:engagement_id>/metadata')
98+
API.add_namespace(ENGAGEMENT_MEMBERS_API,
99+
path='/engagements/<string:engagement_id>/members')
100+
API.add_namespace(WIDGET_DOCUMENTS_API,
101+
path='/widgets/<string:widget_id>/documents')
99102
API.add_namespace(WIDGET_EVENTS_API, path='/widgets/<int:widget_id>/events')
100103
API.add_namespace(WIDGET_MAPS_API, path='/widgets/<int:widget_id>/maps')
101-
API.add_namespace(ENGAGEMENT_SLUG_API, path='/slugs')
102-
API.add_namespace(REPORT_SETTING_API, path='/surveys/<int:survey_id>/reportsettings')
104+
API.add_namespace(REPORT_SETTING_API,
105+
path='/surveys/<int:survey_id>/reportsettings')
103106
API.add_namespace(WIDGET_VIDEO_API, path='/widgets/<int:widget_id>/videos')
104-
API.add_namespace(WIDGET_LISTENING_API, path='/widgets/<int:widget_id>/listening_widgets')
107+
API.add_namespace(WIDGET_LISTENING_API,
108+
path='/widgets/<int:widget_id>/listening_widgets')
105109
API.add_namespace(ENGAGEMENT_SETTINGS_API)
106-
API.add_namespace(WIDGET_TIMELINE_API, path='/widgets/<int:widget_id>/timelines')
110+
API.add_namespace(WIDGET_TIMELINE_API,
111+
path='/widgets/<int:widget_id>/timelines')
107112
API.add_namespace(WIDGET_POLL_API, path='/widgets/<int:widget_id>/polls')
108113
API.add_namespace(WIDGET_IMAGE_API, path='/widgets/<int:widget_id>/images')
109114
API.add_namespace(LANGUAGE_API, path='/languages')
110-
API.add_namespace(SURVEY_TRANSLATION_API, path='/surveys/<int:survey_id>/translations')
111-
API.add_namespace(POLL_ANSWER_TRANSLATION_API, path='/polls/<int:poll_id>/translations')
115+
API.add_namespace(SURVEY_TRANSLATION_API,
116+
path='/surveys/<int:survey_id>/translations')
117+
API.add_namespace(POLL_ANSWER_TRANSLATION_API,
118+
path='/polls/<int:poll_id>/translations')
112119
API.add_namespace(CONTACT_TRANSLATION_API, path='/contact_translations')
113-
API.add_namespace(EVENT_ITEM_TRANSLATION_API, path='/events/<int:event_id>/translations')
114-
API.add_namespace(TIMELINE_EVENT_TRANSLATION_API, path='/timelines/<int:timeline_id>/translations')
115-
API.add_namespace(ENGAGEMENT_TRANSLATION_API, path='/engagement/<int:engagement_id>/translations')
116-
API.add_namespace(ENGAGEMENT_DETAILS_TAB_TRANSLATION_API, path='/engagement/<int:engagement_id>/details/translations')
117-
API.add_namespace(ENGAGEMENT_CONTENT_TRANSLATION_API, path='/engagement/<int:engagement_id>/content/translations')
120+
API.add_namespace(EVENT_ITEM_TRANSLATION_API,
121+
path='/events/<int:event_id>/translations')
122+
API.add_namespace(TIMELINE_EVENT_TRANSLATION_API,
123+
path='/timelines/<int:timeline_id>/translations')
124+
API.add_namespace(ENGAGEMENT_TRANSLATION_API,
125+
path='/engagement/<int:engagement_id>/translations')
126+
API.add_namespace(ENGAGEMENT_DETAILS_TAB_TRANSLATION_API,
127+
path='/engagement/<int:engagement_id>/details/translations')
128+
API.add_namespace(ENGAGEMENT_CONTENT_TRANSLATION_API,
129+
path='/engagement/<int:engagement_id>/content/translations')
118130
API.add_namespace(VERSION_API, path='/version')
119-
API.add_namespace(widget_image_translation_api, path='/widget-image-translation')
131+
API.add_namespace(widget_image_translation_api,
132+
path='/widget-image-translation')

api/src/api/resources/engagement.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,33 @@
1313
# limitations under the License.
1414
"""API endpoints for managing an engagement resource."""
1515

16+
import json
1617
from http import HTTPStatus
1718

18-
import json
19-
from flask import request
19+
from flask import current_app, request
2020
from flask_cors import cross_origin
2121
from flask_restx import Namespace, Resource
2222
from marshmallow import ValidationError
2323

2424
from api.auth import auth
2525
from api.auth import jwt as _jwt
2626
from api.models.pagination_options import PaginationOptions
27+
from api.models.tenant import Tenant as TenantModel
2728
from api.schemas.engagement import EngagementSchema
2829
from api.services.engagement_service import EngagementService
2930
from api.utils.roles import Role
3031
from api.utils.tenant_validator import require_role
3132
from api.utils.token_info import TokenInfo
3233
from api.utils.util import allowedorigins, cors_preflight
3334

35+
3436
API = Namespace(
3537
'engagements', description='Endpoints for Engagements Management')
3638
"""Custom exception messages
3739
"""
3840

41+
ENGAGEMENT_NOT_FOUND = 'Engagement was not found'
42+
3943

4044
@cors_preflight('GET,OPTIONS')
4145
@API.route('/<engagement_id>')
@@ -53,9 +57,36 @@ def get(engagement_id):
5357
if engagement_record:
5458
return engagement_record, HTTPStatus.OK
5559

56-
return 'Engagement was not found', HTTPStatus.INTERNAL_SERVER_ERROR
60+
return ENGAGEMENT_NOT_FOUND, HTTPStatus.NOT_FOUND
5761
except KeyError:
58-
return 'Engagement was not found', HTTPStatus.INTERNAL_SERVER_ERROR
62+
return ENGAGEMENT_NOT_FOUND, HTTPStatus.NOT_FOUND
63+
except ValueError as err:
64+
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR
65+
66+
67+
@cors_preflight('GET,OPTIONS')
68+
@API.route('/slug/<slug>')
69+
class EngagementBySlug(Resource):
70+
"""Resource for fetching a single engagement by slug."""
71+
72+
@staticmethod
73+
@cross_origin(origins=allowedorigins())
74+
@auth.optional
75+
def get(slug):
76+
"""Fetch a single engagement matching the provided slug."""
77+
default_tenant = current_app.config.get(
78+
'DEFAULT_TENANT_SHORT_NAME', 'default')
79+
tenant_short_name = request.headers.get('tenant-id', default_tenant)
80+
tenant = TenantModel.find_by_short_name(tenant_short_name)
81+
try:
82+
engagement_record = EngagementService().get_engagement_by_slug(slug, tenant.id)
83+
84+
if engagement_record:
85+
return engagement_record, HTTPStatus.OK
86+
87+
return ENGAGEMENT_NOT_FOUND, HTTPStatus.NOT_FOUND
88+
except KeyError:
89+
return ENGAGEMENT_NOT_FOUND, HTTPStatus.NOT_FOUND
5990
except ValueError as err:
6091
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR
6192

@@ -74,7 +105,7 @@ def delete(engagement_id: int):
74105
EngagementService.delete(engagement_id)
75106
return {'id': engagement_id}, HTTPStatus.OK
76107
except KeyError as err:
77-
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR
108+
return str(err), HTTPStatus.NOT_FOUND
78109
except ValueError as err:
79110
return str(err), HTTPStatus.BAD_REQUEST
80111

@@ -159,11 +190,11 @@ def post():
159190
engagement_model = EngagementService().create_engagement(requestjson)
160191
return engagement_schema.dump(engagement_model), HTTPStatus.OK
161192
except KeyError as err:
162-
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR
193+
return str(err), HTTPStatus.NOT_FOUND
163194
except ValueError as err:
164-
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR
195+
return str(err), HTTPStatus.NOT_FOUND
165196
except ValidationError as err:
166-
return str(err.messages), HTTPStatus.INTERNAL_SERVER_ERROR
197+
return str(err.messages), HTTPStatus.BAD_REQUEST
167198

168199
@staticmethod
169200
@cross_origin(origins=allowedorigins())
@@ -177,12 +208,14 @@ def patch():
177208

178209
engagement_schema = EngagementSchema()
179210
payload = engagement_schema.load(requestjson, partial=True)
211+
if not isinstance(payload, dict):
212+
return 'Invalid engagement payload', HTTPStatus.BAD_REQUEST
180213
engagement = EngagementService().edit_engagement(payload)
181214

182215
return engagement_schema.dump(engagement), HTTPStatus.OK
183216
except KeyError as err:
184-
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR
217+
return str(err), HTTPStatus.NOT_FOUND
185218
except ValueError as err:
186-
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR
219+
return str(err), HTTPStatus.NOT_FOUND
187220
except ValidationError as err:
188221
return str(err.messages), HTTPStatus.BAD_REQUEST

0 commit comments

Comments
 (0)