diff --git a/.github/workflows/_build.yaml b/.github/workflows/_build.yaml index c94f9bd3e7c9..ed73a8fa390a 100644 --- a/.github/workflows/_build.yaml +++ b/.github/workflows/_build.yaml @@ -94,26 +94,26 @@ jobs: ${{ env.ECR_REPO }}:${{ steps.sha.outputs.SHORT_SHA }} platforms: linux/amd64,linux/arm64 - # TODO What is this used for? Leftover artifact from Jenkins - - name: Extract Jira ticket numbers from commit message - run: | - tickets=$(git log -1 --pretty=%B | grep -io 'M2-[0-9]\+' | tr '[:lower:]' '[:upper:]' | sort | uniq | tr '\n' ' ') - if [ -z "$tickets" ]; then - echo "No Jira tickets found in the latest commit message." - else - echo "Jira ticket numbers found: ${tickets}" - if [ "${GITHUB_BRANCH_OR_TAG}" != "develop" ]; then - echo "Skipping saving Jira ticket numbers to a file because the branch is not develop" - else - file="jira-tickets.json" - echo "{ \"issues\": $(echo "${tickets}" | jq -R -s -c 'split(" ")[:-1]') }" > "${file}" - echo "Jira ticket numbers saved to ${file}" - fi - fi - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: build-artifacts - path: | - ${GITHUB_REPO_NAME}/${OUT_DIR}/jira-tickets.json +# # TODO What is this used for? Leftover artifact from Jenkins +# - name: Extract Jira ticket numbers from commit message +# run: | +# tickets=$(git log -1 --pretty=%B | grep -io 'M2-[0-9]\+' | tr '[:lower:]' '[:upper:]' | sort | uniq | tr '\n' ' ') +# if [ -z "$tickets" ]; then +# echo "No Jira tickets found in the latest commit message." +# else +# echo "Jira ticket numbers found: ${tickets}" +# if [ "${GITHUB_BRANCH_OR_TAG}" != "develop" ]; then +# echo "Skipping saving Jira ticket numbers to a file because the branch is not develop" +# else +# file="jira-tickets.json" +# echo "{ \"issues\": $(echo "${tickets}" | jq -R -s -c 'split(" ")[:-1]') }" > "${file}" +# echo "Jira ticket numbers saved to ${file}" +# fi +# fi +# +# - name: Upload artifacts +# uses: actions/upload-artifact@v4 +# with: +# name: build-artifacts +# path: | +# ${GITHUB_REPO_NAME}/${OUT_DIR}/jira-tickets.json diff --git a/.github/workflows/_deploy.yaml b/.github/workflows/_deploy.yaml index 1005524d5139..8be61bd52506 100644 --- a/.github/workflows/_deploy.yaml +++ b/.github/workflows/_deploy.yaml @@ -83,7 +83,12 @@ jobs: task-definition: task-definition.json container-name: ${{ matrix.apps.container }} image: ${{ needs.setup-vars.outputs.ecr-repo }}/api-server:${{ inputs.image-tag }} - + environment-variables: | + VERSION=${{ inputs.image-tag }} + DD_VERSION=${{ inputs.image-tag }} + SENTRY_RELEASE=${{ inputs.image-tag }} + docker-labels: | + com.datadoghq.tags.version=${{ inputs.image-tag }} - name: Update Task Definition uses: aws-actions/amazon-ecs-deploy-task-definition@v2 with: @@ -116,6 +121,20 @@ jobs: - deploy steps: - uses: actions/checkout@v4 + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::917902836630:role/ssm-versions-oidc-github-role + role-session-name: OIDC-GHA-session-version + aws-region: us-east-1 + - name: Store version in SSM + run: | + aws ssm put-parameter \ + --name "/curious/app/backend/${{ inputs.env-name }}/version" \ + --value "${{ inputs.image-tag }}" \ + --type "String" \ + --overwrite + - name: "Send Slack message on success" uses: rtCamp/action-slack-notify@v2 env: @@ -123,4 +142,4 @@ jobs: SLACK_WEBHOOK: ${{ secrets.slack-webhook }} MSG_MINIMAL: actions url SLACK_TITLE: Deployment to ${{ inputs.env-name }} environment - SLACK_MESSAGE: '🚀 Deployment was successful!' + SLACK_MESSAGE: '🚀 Deployment was successful. Version: *${{ inputs.image-tag }}*' diff --git a/.github/workflows/_migrations.yaml b/.github/workflows/_migrations.yaml index 6a7422d9846c..fcf06fa2c8cc 100644 --- a/.github/workflows/_migrations.yaml +++ b/.github/workflows/_migrations.yaml @@ -56,9 +56,12 @@ jobs: image: ${{ needs.setup-vars.outputs.ecr-repo }}/api-server:${{ inputs.image-tag }} command: /fastapi-migrate environment-variables: | + VERSION=${{ inputs.image-tag }} DD_VERSION=${{ inputs.image-tag }} + SENTRY_RELEASE=${{ inputs.image-tag }} docker-labels: | com.datadoghq.tags.version=${{ inputs.image-tag }} + - name: Update Task Definition id: taskdef uses: aws-actions/amazon-ecs-deploy-task-definition@v2 diff --git a/.github/workflows/run_build_deploy_dev.yaml b/.github/workflows/run_build_deploy_dev.yaml index 8e6c043843ff..558d42e08287 100644 --- a/.github/workflows/run_build_deploy_dev.yaml +++ b/.github/workflows/run_build_deploy_dev.yaml @@ -18,29 +18,45 @@ permissions: jobs: + version: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Get git short sha + id: sha + run: | + echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + outputs: + short-sha: ${{ steps.sha.outputs.sha }} + build: name: Docker Build and Push + needs: version secrets: inherit uses: ./.github/workflows/_build.yaml + with: + tag-name: ${{ needs.version.outputs.short-sha }} migrations: - needs: build + needs: [version, build] name: Run Migrations in Dev secrets: slack-webhook: ${{ secrets.SLACK_WEBHOOK_DEPLOY_TO_DEV }} uses: ./.github/workflows/_migrations.yaml with: env-name: dev - image-tag: latest + image-tag: ${{ needs.version.outputs.short-sha }} deploy: name: Deploy to Dev - needs: [ build, migrations ] + needs: [ version, build, migrations ] secrets: slack-webhook: ${{ secrets.SLACK_WEBHOOK_DEPLOY_TO_DEV }} uses: ./.github/workflows/_deploy.yaml with: - image-tag: latest + image-tag: ${{ needs.version.outputs.short-sha }} env-name: dev run-e2e-tests: diff --git a/.github/workflows/run_build_deploy_prod.yaml b/.github/workflows/run_build_deploy_prod.yaml index 92c485732875..d2dd27f545f3 100644 --- a/.github/workflows/run_build_deploy_prod.yaml +++ b/.github/workflows/run_build_deploy_prod.yaml @@ -1,6 +1,9 @@ name: Build and Deploy to Prod on: + release: + types: + - released workflow_dispatch: inputs: target-ref: @@ -19,53 +22,50 @@ permissions: issues: write jobs: -# ref: -# name: Get target ref -# runs-on: ubuntu-latest -# outputs: -# target-ref: ${{ steps.ref.outputs.ref }} -# steps: -# - id: ref -# name: Select Ref -# run: | -# TARGET_REF="${{ github.event.inputs.target_ref }}" -# # Use shell parameter expansion to provide a default value if input is unset or empty -# # github.ref_name will be used if the input is not provided or empty -# # Remove "refs/heads/" or "refs/tags/" if present in github.ref_name -# TARGET_REF=${TARGET_REF:-$(echo "${{ github.ref_name }}" | sed 's/refs\/heads\///g' | sed 's/refs\/tags\///g')} -# -# echo "Target reference: $TARGET_REF" -# echo "ref=$TARGET_REF" >> $GITHUB_OUTPUT + ref: + name: Get target ref + runs-on: ubuntu-latest + outputs: + target-ref: ${{ steps.ref.outputs.ref }} + steps: + - id: ref + name: Select Ref + run: | + TARGET_REF="${{ github.event.inputs.target-ref }}" + # Use shell parameter expansion to provide a default value if input is unset or empty + # github.ref_name will be used if the input is not provided or empty + # Remove "refs/heads/" or "refs/tags/" if present in github.ref_name + TARGET_REF=${TARGET_REF:-$(echo "${{ github.event.release.tag_name }}" | sed 's/refs\/heads\///g' | sed 's/refs\/tags\///g')} + + echo "Target reference: $TARGET_REF" + echo "ref=$TARGET_REF" >> $GITHUB_OUTPUT build: name: Build -# needs: ref + needs: ref secrets: inherit uses: ./.github/workflows/_build.yaml with: - tag-name: ${{ inputs.target-ref }} -# tag-name: ${{ needs.ref.outputs.target-ref }} - #${{ github.ref_name }} + tag-name: ${{ needs.ref.outputs.target-ref }} + git-ref: ${{ needs.ref.outputs.target-ref }} migrations: name: Run Migrations in Prod - needs: build + needs: [build, ref] secrets: slack-webhook: ${{ secrets.SLACK_WEBHOOK_DEPLOY_TO_PROD }} uses: ./.github/workflows/_migrations.yaml with: env-name: prod - image-tag: ${{ inputs.target-ref }} -# image-tag: ${{ needs.ref.outputs.target-ref }} + image-tag: ${{ needs.ref.outputs.target-ref }} deploy-prod: name: Deploy to Prod - needs: [ build, migrations ] + needs: [ build, migrations, ref ] secrets: - slack-webhook: ${{ secrets.SLACK_WEBHOOK_DEPLOY_TO_Prod }} + slack-webhook: ${{ secrets.SLACK_WEBHOOK_DEPLOY_TO_PROD }} uses: ./.github/workflows/_deploy.yaml with: - image-tag: ${{ inputs.target-ref }} -# image-tag: ${{ needs.ref.outputs.target-ref }} env-name: prod + image-tag: ${{ needs.ref.outputs.target-ref }} diff --git a/src/apps/activities/commands/__init__.py b/src/apps/activities/commands/__init__.py index 17240108a9a7..e69de29bb2d1 100644 --- a/src/apps/activities/commands/__init__.py +++ b/src/apps/activities/commands/__init__.py @@ -1,3 +0,0 @@ -from apps.activities.commands.reindex_items import app as activities - -__all__ = ["activities"] diff --git a/src/apps/activities/commands/delete_subscales.py b/src/apps/activities/commands/delete_subscales.py new file mode 100644 index 000000000000..9923316da1a6 --- /dev/null +++ b/src/apps/activities/commands/delete_subscales.py @@ -0,0 +1,91 @@ +from uuid import UUID + +import typer +from sqlalchemy import text + +from infrastructure.commands.utils import coro +from infrastructure.database import session_manager + +app = typer.Typer(help="Delete subscales and score-type reports across all versions of an applet.") + +DROP_SCORE_FUNC_SQL = """ +CREATE OR REPLACE FUNCTION drop_score_reports(sr jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$ + SELECT CASE + WHEN sr ? 'reports' THEN + jsonb_set( + sr, '{reports}', + COALESCE( + (SELECT jsonb_agg(r) + FROM jsonb_array_elements(sr->'reports') r + WHERE r->>'type' <> 'score'), + '[]'::jsonb + ), + true + ) + ELSE sr + END; +$$; +""" + + +@app.command(name="delete-subscales-for-all-applet-versions") +@coro +async def delete_subscales_for_all_applet_versions( + applet_id: UUID = typer.Argument(..., help="Base applet ID (UUID)"), +) -> None: + """ + Remove all subscales and score-type reports from every version of the specified applet. + """ + # obtain a session maker; then call it to get an async session + s_maker = session_manager.get_session() + async with s_maker() as session: + await session.execute(text(DROP_SCORE_FUNC_SQL)) + await session.commit() + + # Get the list of version IDs for this applet + versions_sql = text(""" + SELECT id_version + FROM applet_histories + WHERE id = :applet_id + """) + versions = ( + ( + await session.execute( + versions_sql, + {"applet_id": applet_id}, + ) + ) + .scalars() + .all() + ) + + if not versions: + typer.echo(f"No history versions found for applet {applet_id}.") + return + + # Delete subscales and score-type reports in both tables + hist_update_sql = text(""" + UPDATE activity_histories + SET subscale_setting = NULL, + scores_and_reports = CASE + WHEN scores_and_reports IS NULL THEN NULL + ELSE drop_score_reports(scores_and_reports) + END + WHERE applet_id = ANY(:versions) + """) + live_update_sql = text(""" + UPDATE activities + SET subscale_setting = NULL, + scores_and_reports = CASE + WHEN scores_and_reports IS NULL THEN NULL + ELSE drop_score_reports(scores_and_reports) + END + WHERE applet_id = :applet_id + """) + + await session.execute(hist_update_sql, {"versions": versions}) + await session.execute(live_update_sql, {"applet_id": applet_id}) + await session.commit() + + typer.echo(f"Deleted subscales and score-type reports for {len(versions)} version(s) of applet {applet_id}.") diff --git a/src/apps/applets/tests/unit/test_applet_crud.py b/src/apps/applets/tests/unit/test_applet_crud.py index b942871ed7fb..fc0baf3b5524 100644 --- a/src/apps/applets/tests/unit/test_applet_crud.py +++ b/src/apps/applets/tests/unit/test_applet_crud.py @@ -1,7 +1,6 @@ import uuid import pytest -from pytest import FixtureRequest from pytest_mock import MockerFixture from sqlalchemy.ext.asyncio import AsyncSession @@ -105,27 +104,35 @@ async def test_get_applets_by_display_name__applet_deleted(applet: AppletSchema, @pytest.mark.parametrize( - "applet_fixture_name, require_login, result", + "require_login, result", ( - ("applet_with_link", False, None), - ("applet_with_link", True, "applet"), - ("applet_with_public_link", False, "applet"), - ("applet_with_public_link", True, None), + (False, applet), # public + (True, None), # public ), ) -async def test_get_applets_by_link( - session: AsyncSession, - applet_fixture_name: str, - require_login: bool, - result: str | None, - request: FixtureRequest, +async def test_public_applet( + session: AsyncSession, applet_with_public_link: AppletSchema, require_login: bool, result: AppletSchema | None ) -> None: - applet = request.getfixturevalue(applet_fixture_name) - expected = applet.id if result is not None else None crud = AppletsCRUD(session) - instance = await crud.get_by_link(applet.link, require_login) - actual = instance.id if instance else None - assert actual == expected + instance = await crud.get_by_link(applet_with_public_link.link, require_login) + if instance: + assert instance.id == applet_with_public_link.id + + +@pytest.mark.parametrize( + "require_login, result", + ( + (False, None), # link + (True, applet), # link + ), +) +async def test_private_applet( + session: AsyncSession, applet_with_link: AppletSchema, require_login: bool, result: AppletSchema | None +) -> None: + crud = AppletsCRUD(session) + instance = await crud.get_by_link(applet_with_link.link, require_login) + if instance: + assert instance.id == applet_with_link.id async def test_get_by_id(applet: AppletSchema, session: AsyncSession): diff --git a/src/apps/invitations/domain.py b/src/apps/invitations/domain.py index d0a01161bf7b..2f6924f2bf6b 100644 --- a/src/apps/invitations/domain.py +++ b/src/apps/invitations/domain.py @@ -20,6 +20,7 @@ class InvitationLanguage(StrEnum): FR = "fr" # French EL = "el" # Greek ES = "es" # Spanish + PT = "pt" # Portuguese class InvitationRequest(InternalModel): diff --git a/src/apps/logs/domain/notification.py b/src/apps/logs/domain/notification.py index fa369e8139b0..b041e61c0c13 100644 --- a/src/apps/logs/domain/notification.py +++ b/src/apps/logs/domain/notification.py @@ -31,6 +31,38 @@ class NotificationLogCreate(_NotificationLogBase, InternalModel): notification_in_queue: list[dict] | None scheduled_notifications: list[dict] | None + @validator( + "notification_descriptions", + "notification_in_queue", + "scheduled_notifications", + pre=True, + ) + def convert_string_to_list(cls, v): + """Convert legacy JSON string format to array format for backward compatibility. + + Mobile apps < v1.0.8 send notification fields as JSON strings. + This validator ensures compatibility by converting strings to arrays. + """ + if v is None: + return None + + if isinstance(v, str): + try: + parsed = json.loads(v) + if isinstance(parsed, list): + return parsed + elif isinstance(parsed, dict): + return [parsed] + return None + except json.JSONDecodeError: + # Handle malformed JSON gracefully + return None + + if isinstance(v, list): + return v + + return None + @root_validator def validate_json_fields(cls, values): if not any( diff --git a/src/apps/mailing/static/templates/blocks/team_info_pt.html b/src/apps/mailing/static/templates/blocks/team_info_pt.html new file mode 100644 index 000000000000..bcec7b77dd0a --- /dev/null +++ b/src/apps/mailing/static/templates/blocks/team_info_pt.html @@ -0,0 +1,5 @@ + + + – A equipe Curious + + diff --git a/src/apps/mailing/static/templates/footers/footer_info_pt.html b/src/apps/mailing/static/templates/footers/footer_info_pt.html new file mode 100644 index 000000000000..2cd84eccbe12 --- /dev/null +++ b/src/apps/mailing/static/templates/footers/footer_info_pt.html @@ -0,0 +1,44 @@ + + + + + + + + + + +
+ + + + + + + + + + + +
Baixe o aplicativo móvel Curious:
+ + Baixe o aplicativo para iOS + + + + Baixe o aplicativo para Android + +
+ Precisa de ajuda? Visite nossa Central de Ajuda. +
+
+ Child Mind Institute +
+ O Child Mind Institute é o criador do Curious, mas não é responsável pelo conteúdo criado por terceiros. +
diff --git a/src/apps/mailing/static/templates/invitation_new_user_pt.html b/src/apps/mailing/static/templates/invitation_new_user_pt.html new file mode 100644 index 000000000000..c431137daaf6 --- /dev/null +++ b/src/apps/mailing/static/templates/invitation_new_user_pt.html @@ -0,0 +1,28 @@ + + + + {% include 'header.html' %} + + + + + + + + + + + {% include 'blocks/team_info_pt.html' %} +
Olá {{ first_name }},
+ Você foi convidado a se tornar + {% if role[0]|lower in ['a', 'e', 'i', 'o', 'u'] %} an {% else %} a {% endif %} + {{ role }} de "{{ applet_name }}" no Curious, uma plataforma de coleta de dados sobre saúde mental e desafios de aprendizagem. +
+ + Crie sua conta e comece a usar + +
+ {% include 'footers/footer_info_pt.html' %} + + + diff --git a/src/apps/mailing/static/templates/invitation_registered_user_pt.html b/src/apps/mailing/static/templates/invitation_registered_user_pt.html new file mode 100644 index 000000000000..f240995da20b --- /dev/null +++ b/src/apps/mailing/static/templates/invitation_registered_user_pt.html @@ -0,0 +1,28 @@ + + + + {% include 'header.html' %} + + + + + + + + + + + {% include 'blocks/team_info_pt.html' %} +
Olá {{ first_name }},
+ Você foi convidado a se tornar + {% if role[0]|lower in ['a', 'e', 'i', 'o', 'u'] %} an {% else %} a {% endif %} + {{ role }} de "{{ applet_name }}" no Curious. +
+ + Comece a usar + +
+ {% include 'footers/footer_info_pt.html' %} + + + diff --git a/src/apps/mailing/static/templates/new_activity_assignments_pt.html b/src/apps/mailing/static/templates/new_activity_assignments_pt.html new file mode 100644 index 000000000000..c726fc00d401 --- /dev/null +++ b/src/apps/mailing/static/templates/new_activity_assignments_pt.html @@ -0,0 +1,36 @@ + + + + {% include 'header.html' %} + + + + + + + +
Olá {{ first_name }},
+ Você recebeu uma ou mais atividades em "{{ applet_name }}". +
+ + + + + + + + + {% include 'blocks/team_info_pt.html' %} +
Faça login para revisar os detalhes da tarefa no Curious.
+ + Comece a usar + +
+ {% include 'footers/footer_info_pt.html' %} + + + diff --git a/src/apps/mailing/static/templates/reset_password_pt.html b/src/apps/mailing/static/templates/reset_password_pt.html new file mode 100644 index 000000000000..ae710a58efbe --- /dev/null +++ b/src/apps/mailing/static/templates/reset_password_pt.html @@ -0,0 +1,31 @@ + + + {% include 'header.html' %} + + + + + + + + + + + + + + {% include 'blocks/team_info_pt.html' %} +
+ Recebemos uma solicitação para redefinir sua senha. +
+ Clique neste link para redefinir sua senha agora. O link irá expirar em 15 minutos. +
+ Se você não fez essa solicitação, você pode ignorar este e-mail. +
+ + Após atualizar sua senha, você não verá seus dados anteriores no aplicativo. Mas não se preocupe, nós ainda temos acesso a eles. + +
+ {% include 'footers/footer_info_pt.html' %} + + diff --git a/src/cli.py b/src/cli.py index 37433d773075..c2ed878a4eea 100755 --- a/src/cli.py +++ b/src/cli.py @@ -7,7 +7,9 @@ import typer # noqa: E402,I001 -from apps.activities.commands import activities # noqa: E402 +from apps.activities.commands.reindex_items import app as reindex_items_cli # noqa: E402 +from apps.activities.commands.delete_subscales import app as delete_subscales_cli # noqa: E402 + from apps.answers.commands import convert_assessments # noqa: E402 from apps.applets.commands import ( # noqa: E402 applet_cli, # noqa: E402 @@ -21,7 +23,8 @@ cli = typer.Typer() cli.add_typer(arbitrary_server_cli, name="arbitrary") cli.add_typer(convert_assessments, name="assessments") -cli.add_typer(activities, name="activities") +cli.add_typer(reindex_items_cli, name="reindex") +cli.add_typer(delete_subscales_cli, name="delete-subscales") cli.add_typer(token_cli, name="token") cli.add_typer(patch, name="patch") cli.add_typer(encryption_cli, name="encryption") diff --git a/src/infrastructure/database/migrations/versions/2025_05_21_10_55-clean_up_applet_events_table.py b/src/infrastructure/database/migrations/versions/2025_05_21_10_55-clean_up_applet_events_table.py new file mode 100644 index 000000000000..d8c25480648f --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2025_05_21_10_55-clean_up_applet_events_table.py @@ -0,0 +1,109 @@ +"""Clean up applet_events table + +Revision ID: b35481672766 +Revises: a05c838b8aad +Create Date: 2025-05-21 10:55:15.571446 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "b35481672766" +down_revision = "a05c838b8aad" # This revision ID does not exist on the develop branch yet, so this PR should not be merged until it does +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create a temporary table to remove the deleted records + # Should be removed in a subsequent migration + op.create_table( + "applet_events_cleanup", + sa.Column("is_deleted", sa.Boolean(), nullable=True), + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("migrated_date", sa.DateTime(), nullable=True), + sa.Column("migrated_updated", sa.DateTime(), nullable=True), + sa.Column("applet_id", sa.String(), nullable=False), + sa.Column("event_id", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_applet_events_cleanup")), + sa.UniqueConstraint("applet_id", "event_id", "is_deleted", name="_unique_applet_events_cleanup"), + ) + + # Archive the rows that will be deleted + op.execute(""" + INSERT INTO applet_events_cleanup (is_deleted, id, created_at, updated_at, + migrated_date, + migrated_updated, + applet_id, + event_id) + SELECT applet_events.is_deleted, + applet_events.id, + applet_events.created_at, + applet_events.updated_at, + applet_events.migrated_date, + applet_events.migrated_updated, + applet_events.applet_id, + applet_events.event_id + FROM event_histories + JOIN applet_events ON event_histories.id_version = applet_events.event_id + JOIN applet_histories ON applet_events.applet_id = applet_histories.id_version + LEFT OUTER JOIN subjects + ON event_histories.user_id = subjects.user_id AND + applet_histories.id = subjects.applet_id + LEFT OUTER JOIN activity_histories + ON event_histories.activity_id = activity_histories.id AND + applet_histories.id_version = activity_histories.applet_id + LEFT OUTER JOIN flow_histories + ON event_histories.activity_flow_id = flow_histories.id AND + applet_histories.id_version = flow_histories.applet_id + WHERE coalesce(flow_histories.applet_id, activity_histories.applet_id) IS NULL + """) + + # Delete entries from applet_events + op.execute(""" + DELETE FROM applet_events + WHERE id IN ( + SELECT applet_events.id + FROM event_histories + JOIN applet_events ON event_histories.id_version = applet_events.event_id + JOIN applet_histories ON applet_events.applet_id = applet_histories.id_version + LEFT OUTER JOIN subjects + ON event_histories.user_id = subjects.user_id AND + applet_histories.id = subjects.applet_id + LEFT OUTER JOIN activity_histories + ON event_histories.activity_id = activity_histories.id AND + applet_histories.id_version = activity_histories.applet_id + LEFT OUTER JOIN flow_histories + ON event_histories.activity_flow_id = flow_histories.id AND + applet_histories.id_version = flow_histories.applet_id + WHERE coalesce(flow_histories.applet_id, activity_histories.applet_id) IS NULL + ) + """) + + +def downgrade() -> None: + # Restore the archived rows + op.execute(""" + INSERT INTO applet_events (is_deleted, id, created_at, updated_at, + migrated_date, + migrated_updated, + applet_id, + event_id) + SELECT is_deleted, + id, + created_at, + updated_at, + migrated_date, + migrated_updated, + applet_id, + event_id + FROM applet_events_cleanup + """) + + # Drop the archive table + op.drop_table('applet_events_cleanup') diff --git a/src/infrastructure/database/migrations/versions/2025_08_28_13_10-merge_multiple_heads.py b/src/infrastructure/database/migrations/versions/2025_08_28_13_10-merge_multiple_heads.py new file mode 100644 index 000000000000..8e697ce84b78 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2025_08_28_13_10-merge_multiple_heads.py @@ -0,0 +1,23 @@ +"""Merge multiple heads + +Revision ID: e7fc7c11c8a3 +Revises: 8ec71571fa71, b35481672766 +Create Date: 2025-08-28 13:10:44.003205 + +""" + + + +# revision identifiers, used by Alembic. +revision = "e7fc7c11c8a3" +down_revision = ("8ec71571fa71", "b35481672766") +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass