From cca8ef274b55d5e46b4de7ea3a8cc139f5b27be3 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Thu, 29 Feb 2024 14:54:30 -0800 Subject: [PATCH 01/22] Adding edit functionality for properties --- .../privacy-experience/NewJavaScriptTag.tsx | 2 +- .../features/properties/PropertiesTable.tsx | 2 +- .../src/features/properties/PropertyForm.tsx | 65 +++++++++---------- .../src/features/properties/property.slice.ts | 21 ++++-- .../src/pages/consent/properties/[id].tsx | 39 ++++++++--- .../pages/consent/properties/add-property.tsx | 46 +++++++++---- .../admin-ui/src/types/api/models/Property.ts | 2 +- ...5b0db5b14_drop_key_column_from_property.py | 29 +++++++++ src/fides/api/models/property.py | 16 ++++- src/fides/api/schemas/property.py | 20 +----- tests/ops/models/test_property.py | 28 +++++++- 11 files changed, 185 insertions(+), 85 deletions(-) create mode 100644 src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py diff --git a/clients/admin-ui/src/features/privacy-experience/NewJavaScriptTag.tsx b/clients/admin-ui/src/features/privacy-experience/NewJavaScriptTag.tsx index 52d3fee273..44d45a44cf 100644 --- a/clients/admin-ui/src/features/privacy-experience/NewJavaScriptTag.tsx +++ b/clients/admin-ui/src/features/privacy-experience/NewJavaScriptTag.tsx @@ -43,7 +43,7 @@ const NewJavaScriptTag = ({ property }: Props) => { const fidesJsScriptTag = useMemo(() => { const script = FIDES_JS_SCRIPT_TEMPLATE.replace( PROPERTY_UNIQUE_ID_TEMPLATE, - property.key.toString() + property.id.toString() ); if (isFidesCloud && isSuccess && fidesCloudConfig?.privacy_center_url) { script.replace( diff --git a/clients/admin-ui/src/features/properties/PropertiesTable.tsx b/clients/admin-ui/src/features/properties/PropertiesTable.tsx index d85aa1ccaa..115989dd52 100644 --- a/clients/admin-ui/src/features/properties/PropertiesTable.tsx +++ b/clients/admin-ui/src/features/properties/PropertiesTable.tsx @@ -157,7 +157,7 @@ export const PropertiesTable = () => { }); const onRowClick = (property: Property) => { - router.push(`${PROPERTIES_ROUTE}/${property.key}`); + router.push(`${PROPERTIES_ROUTE}/${property.id}`); }; if (isLoading || isLoadingHealthCheck) { diff --git a/clients/admin-ui/src/features/properties/PropertyForm.tsx b/clients/admin-ui/src/features/properties/PropertyForm.tsx index 2d1ecb761b..d65f0e9016 100644 --- a/clients/admin-ui/src/features/properties/PropertyForm.tsx +++ b/clients/admin-ui/src/features/properties/PropertyForm.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Flex, useToast } from "@fidesui/react"; +import { Box, Button, Flex } from "@fidesui/react"; import { Form, Formik } from "formik"; import { useRouter } from "next/router"; import { useMemo } from "react"; @@ -11,17 +11,12 @@ import { CustomSelect, CustomTextInput, } from "../common/form/inputs"; -import { - enumToOptions, - getErrorMessage, - isErrorResult, -} from "../common/helpers"; +import { enumToOptions } from "../common/helpers"; import { PROPERTIES_ROUTE } from "../common/nav/v2/routes"; -import { errorToastParams, successToastParams } from "../common/toast"; -import { useCreatePropertyMutation } from "./property.slice"; interface Props { property?: Property; + handleSubmit: (values: FormValues) => Promise; } export interface FormValues { @@ -29,29 +24,18 @@ export interface FormValues { type: string; } -const PropertyForm = ({ property }: Props) => { - const toast = useToast(); +const PropertyForm = ({ property, handleSubmit }: Props) => { const router = useRouter(); - const [createProperty] = useCreatePropertyMutation(); + + const handleCancel = () => { + router.push(PROPERTIES_ROUTE); + }; const initialValues = useMemo( () => property || { name: "", type: "" }, [property] ); - const handleSubmit = async (values: FormValues) => { - const result = await createProperty(values); - - if (isErrorResult(result)) { - toast(errorToastParams(getErrorMessage(result.error))); - return; - } - - const prop = result.data; - toast(successToastParams(`Property ${values.name} created successfully`)); - router.push(`${PROPERTIES_ROUTE}/${prop.key}`); - }; - return ( {() => ( @@ -83,8 +67,9 @@ const PropertyForm = ({ property }: Props) => { @@ -92,16 +77,24 @@ const PropertyForm = ({ property }: Props) => { )} - {!property && ( - - )} + + )} diff --git a/clients/admin-ui/src/features/properties/property.slice.ts b/clients/admin-ui/src/features/properties/property.slice.ts index 1052d28d6a..7b152bc55a 100644 --- a/clients/admin-ui/src/features/properties/property.slice.ts +++ b/clients/admin-ui/src/features/properties/property.slice.ts @@ -18,9 +18,9 @@ export const propertiesApi = baseApi.injectEndpoints({ }), providesTags: ["Property"], }), - getPropertyByKey: builder.query({ - query: (key) => ({ - url: `plus/property/${key}`, + getPropertyById: builder.query({ + query: (id) => ({ + url: `plus/property/${id}`, }), providesTags: ["Property"], }), @@ -32,12 +32,23 @@ export const propertiesApi = baseApi.injectEndpoints({ }), invalidatesTags: ["Property"], }), + updateProperty: builder.mutation< + Property, + { key: string; property: Partial } + >({ + query: ({ id, property }) => ({ + url: `plus/property/${id}`, + method: "PUT", + body: property, + }), + invalidatesTags: ["Property"], + }), }), }); -// Export hooks for using the endpoints export const { useGetPropertiesQuery, - useGetPropertyByKeyQuery, + useGetPropertyByIdQuery, useCreatePropertyMutation, + useUpdatePropertyMutation, } = propertiesApi; diff --git a/clients/admin-ui/src/pages/consent/properties/[id].tsx b/clients/admin-ui/src/pages/consent/properties/[id].tsx index 4db7a364e2..22c9beb9b5 100644 --- a/clients/admin-ui/src/pages/consent/properties/[id].tsx +++ b/clients/admin-ui/src/pages/consent/properties/[id].tsx @@ -1,17 +1,38 @@ -import { Box, Heading } from "@fidesui/react"; +import { Box, Heading, Text, useToast } from "@fidesui/react"; import type { NextPage } from "next"; import { useRouter } from "next/router"; +import { getErrorMessage } from "~/features/common/helpers"; import Layout from "~/features/common/Layout"; -import BackButton from "~/features/common/nav/v2/BackButton"; import { PROPERTIES_ROUTE } from "~/features/common/nav/v2/routes"; -import { useGetPropertyByKeyQuery } from "~/features/properties/property.slice"; -import PropertyForm from "~/features/properties/PropertyForm"; +import { errorToastParams, successToastParams } from "~/features/common/toast"; +import { + useGetPropertyByIdQuery, + useUpdatePropertyMutation, +} from "~/features/properties/property.slice"; +import PropertyForm, { FormValues } from "~/features/properties/PropertyForm"; +import { isErrorResult } from "~/types/errors"; const EditPropertyPage: NextPage = () => { + const toast = useToast(); const router = useRouter(); const { id } = router.query; - const { data } = useGetPropertyByKeyQuery(id as string); + const { data } = useGetPropertyByIdQuery(id as string); + const [updateProperty] = useUpdatePropertyMutation(); + + const handleSubmit = async (values: FormValues) => { + const { id, ...updateValues } = values; + + const result = await updateProperty({ id, property: updateValues }); + + if (isErrorResult(result)) { + toast(errorToastParams(getErrorMessage(result.error))); + return; + } + + toast(successToastParams(`Property ${values.name} updated successfully`)); + router.push(`${PROPERTIES_ROUTE}`); + }; if (!data) { return null; @@ -19,14 +40,14 @@ const EditPropertyPage: NextPage = () => { return ( - - {data.name} + Edit {data.name} - - + + Edit your existing property here. + ); diff --git a/clients/admin-ui/src/pages/consent/properties/add-property.tsx b/clients/admin-ui/src/pages/consent/properties/add-property.tsx index acbd613348..d07f152bca 100644 --- a/clients/admin-ui/src/pages/consent/properties/add-property.tsx +++ b/clients/admin-ui/src/pages/consent/properties/add-property.tsx @@ -1,10 +1,15 @@ -import { Box, Heading } from "@fidesui/react"; +import { Box, Heading, Text,useToast } from "@fidesui/react"; import type { NextPage } from "next"; +import router, { useRouter } from "next/router"; +import { getErrorMessage } from "~/features/common/helpers"; import Layout from "~/features/common/Layout"; import BackButton from "~/features/common/nav/v2/BackButton"; import { PROPERTIES_ROUTE } from "~/features/common/nav/v2/routes"; -import PropertyForm from "~/features/properties/PropertyForm"; +import { errorToastParams, successToastParams } from "~/features/common/toast"; +import { useCreatePropertyMutation } from "~/features/properties/property.slice"; +import PropertyForm, { FormValues } from "~/features/properties/PropertyForm"; +import { isErrorResult } from "~/types/errors"; const Header = () => ( @@ -14,14 +19,33 @@ const Header = () => ( ); -const AddPropertyPage: NextPage = () => ( - - -
- - - - -); +const AddPropertyPage: NextPage = () => { + const toast = useToast(); + const router = useRouter(); + const [createProperty] = useCreatePropertyMutation(); + + const handleSubmit = async (values: FormValues) => { + const result = await createProperty(values); + + if (isErrorResult(result)) { + toast(errorToastParams(getErrorMessage(result.error))); + return; + } + + const prop = result.data; + toast(successToastParams(`Property ${values.name} created successfully`)); + router.push(`${PROPERTIES_ROUTE}/${prop.id}`); + }; + + return ( + +
+ + Add new property to Fides here. + + + + ); +}; export default AddPropertyPage; diff --git a/clients/admin-ui/src/types/api/models/Property.ts b/clients/admin-ui/src/types/api/models/Property.ts index 8a004460dd..f5b1f6d4f8 100644 --- a/clients/admin-ui/src/types/api/models/Property.ts +++ b/clients/admin-ui/src/types/api/models/Property.ts @@ -5,7 +5,7 @@ import { PropertyType } from "./PropertyType"; export type Property = { - key: string; + id: string; name: string; type: string; experiences?: Array; diff --git a/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py b/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py new file mode 100644 index 0000000000..9e20b668d4 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py @@ -0,0 +1,29 @@ +"""drop key column from property + +Revision ID: 3815b0db5b14 +Revises: a1e23b70f2b2 +Create Date: 2024-02-29 21:54:38.751678 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "3815b0db5b14" +down_revision = "a1e23b70f2b2" +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_index("ix_plus_property_key", table_name="plus_property") + op.drop_column("plus_property", "key") + + +def downgrade(): + op.add_column( + "plus_property", + sa.Column("key", sa.VARCHAR(), autoincrement=False, nullable=True), + ) + op.create_index("ix_plus_property_key", "plus_property", ["key"], unique=False) diff --git a/src/fides/api/models/property.py b/src/fides/api/models/property.py index f36c9a08de..27215b136c 100644 --- a/src/fides/api/models/property.py +++ b/src/fides/api/models/property.py @@ -1,5 +1,8 @@ from __future__ import annotations +import random +import string + from sqlalchemy import Column, String from sqlalchemy.ext.declarative import declared_attr @@ -16,10 +19,21 @@ class Property(Base): This class serves as a model for digital properties, such as websites or other online platforms. """ + def generate_id(self) -> str: + """ + Generate a unique ID in the format 'FDS-XXXXXX' using uppercase alphanumeric characters. + """ + characters = string.ascii_uppercase + string.digits + return "FDS-" + "".join(random.choices(characters, k=6)) + @declared_attr def __tablename__(self) -> str: return "plus_property" - key = Column(String, index=True, nullable=False, unique=True) + id = Column( + String, + primary_key=True, + default=generate_id, + ) name = Column(String, nullable=False, unique=True) type = Column(EnumColumn(PropertyType), nullable=False) diff --git a/src/fides/api/schemas/property.py b/src/fides/api/schemas/property.py index 5a57931c26..9a38fe9391 100644 --- a/src/fides/api/schemas/property.py +++ b/src/fides/api/schemas/property.py @@ -1,10 +1,5 @@ -from __future__ import annotations - -import re from enum import Enum -from typing import Any, Dict, Optional - -from pydantic import root_validator +from typing import Optional from fides.api.schemas.base_class import FidesSchema @@ -17,15 +12,4 @@ class PropertyType(Enum): class Property(FidesSchema): name: str type: PropertyType - key: Optional[str] = None - - @root_validator - def generate_key(cls, values: Dict[str, Any]) -> Dict[str, Any]: - """ - Generate the property key from the name if not supplied - 1) remove invalid characters - 2) replace spaces with underscores - """ - name = re.sub(r"[^a-zA-Z0-9._<> -]", "", values["name"].lower().strip()) - values["key"] = re.sub(r"\s+", "_", name) - return values + id: Optional[str] = None diff --git a/tests/ops/models/test_property.py b/tests/ops/models/test_property.py index 566b1d56e7..b5700836b5 100644 --- a/tests/ops/models/test_property.py +++ b/tests/ops/models/test_property.py @@ -1,9 +1,23 @@ +from typing import Generator + +import pytest +from sqlalchemy.orm import Session + from fides.api.models.property import Property from fides.api.schemas.property import Property as PropertySchema from fides.api.schemas.property import PropertyType class TestProperty: + @pytest.fixture + def property_a(self, db) -> Generator: + prop_a = Property.create( + db=db, + data=PropertySchema(name="New Property", type=PropertyType.website).dict(), + ) + yield prop_a + prop_a.delete(db=db) + def test_create_property(self, db): prop = Property.create( db=db, @@ -11,7 +25,7 @@ def test_create_property(self, db): ) assert prop.name == "New Property" assert prop.type == PropertyType.website - assert prop.key == "new_property" + assert prop.id.startswith("FDS") def test_create_property_with_special_characters(self, db): prop = Property.create( @@ -22,4 +36,14 @@ def test_create_property_with_special_characters(self, db): ) assert prop.name == "New Property (Prod)" assert prop.type == PropertyType.website - assert prop.key == "new_property_prod" + assert prop.id.startswith("FDS") + + def test_update_property(self, db: Session, property_a): + property_a.name = "Property B" + property_a.type = PropertyType.other + property_a.save(db=db) + + updated_property = Property.get_by(db=db, field="id", value=property_a.id) + assert updated_property.name == "Property B" + assert updated_property.type == PropertyType.other + assert updated_property.id.startswith("FDS") From b655e9266af6093439b6c04755b20f55279c8ee9 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 1 Mar 2024 10:40:24 -0800 Subject: [PATCH 02/22] Adding experiences form section --- .../admin-ui/src/features/common/Layout.tsx | 2 +- .../src/features/properties/PropertyForm.tsx | 162 +++++++++++------- .../pages/consent/properties/add-property.tsx | 5 +- .../admin-ui/src/types/api/models/Property.ts | 4 +- 4 files changed, 105 insertions(+), 68 deletions(-) diff --git a/clients/admin-ui/src/features/common/Layout.tsx b/clients/admin-ui/src/features/common/Layout.tsx index 65b5e87831..155fb2ffb7 100644 --- a/clients/admin-ui/src/features/common/Layout.tsx +++ b/clients/admin-ui/src/features/common/Layout.tsx @@ -54,7 +54,7 @@ const Layout = ({ Fides Admin UI - {title} diff --git a/clients/admin-ui/src/features/properties/PropertyForm.tsx b/clients/admin-ui/src/features/properties/PropertyForm.tsx index d65f0e9016..04478b81d8 100644 --- a/clients/admin-ui/src/features/properties/PropertyForm.tsx +++ b/clients/admin-ui/src/features/properties/PropertyForm.tsx @@ -1,18 +1,25 @@ import { Box, Button, Flex } from "@fidesui/react"; -import { Form, Formik } from "formik"; +import { Form, Formik, useFormikContext } from "formik"; import { useRouter } from "next/router"; import { useMemo } from "react"; import FormSection from "~/features/common/form/FormSection"; import { Property, PropertyType } from "~/types/api"; - import { CustomClipboardCopy, CustomSelect, CustomTextInput, -} from "../common/form/inputs"; -import { enumToOptions } from "../common/helpers"; -import { PROPERTIES_ROUTE } from "../common/nav/v2/routes"; +} from "~/features/common/form/inputs"; +import { enumToOptions } from "~/features/common/helpers"; +import { PROPERTIES_ROUTE } from "~/features/common/nav/v2/routes"; +import ScrollableList from "~/features/common/ScrollableList"; +import { + useGetAllExperienceConfigsQuery, + selectAllExperienceConfigs, + selectPage, + selectPageSize, +} from "~/features/privacy-experience/privacy-experience.slice"; +import { useAppSelector } from "~/app/hooks"; interface Props { property?: Property; @@ -22,8 +29,34 @@ interface Props { export interface FormValues { name: string; type: string; + experiences: Array; } +const ExperiencesFormSection = () => { + const page = useAppSelector(selectPage); + const pageSize = useAppSelector(selectPageSize); + useGetAllExperienceConfigsQuery({ + page, + size: pageSize, + }); + const experienceConfigs = useAppSelector(selectAllExperienceConfigs); + const { values, setFieldValue } = useFormikContext(); + + return ( + + exp.name)} + values={values.experiences ?? []} + setValues={(newValues) => setFieldValue("experiences", newValues)} + getItemLabel={(item) => item} + draggable + /> + + ); +}; + const PropertyForm = ({ property, handleSubmit }: Props) => { const router = useRouter(); @@ -32,72 +65,77 @@ const PropertyForm = ({ property, handleSubmit }: Props) => { }; const initialValues = useMemo( - () => property || { name: "", type: "" }, + () => property || { name: "", type: "", experiences: [] }, [property] ); return ( - - {() => ( -
+ + + + + + + + + + + + {property && ( - - - + - {property && ( - - - - - - )} - - - - - - )} + )} + + + + +
); }; diff --git a/clients/admin-ui/src/pages/consent/properties/add-property.tsx b/clients/admin-ui/src/pages/consent/properties/add-property.tsx index d07f152bca..8ea780f55b 100644 --- a/clients/admin-ui/src/pages/consent/properties/add-property.tsx +++ b/clients/admin-ui/src/pages/consent/properties/add-property.tsx @@ -1,10 +1,9 @@ -import { Box, Heading, Text,useToast } from "@fidesui/react"; +import { Box, Heading, Text, useToast } from "@fidesui/react"; import type { NextPage } from "next"; -import router, { useRouter } from "next/router"; +import { useRouter } from "next/router"; import { getErrorMessage } from "~/features/common/helpers"; import Layout from "~/features/common/Layout"; -import BackButton from "~/features/common/nav/v2/BackButton"; import { PROPERTIES_ROUTE } from "~/features/common/nav/v2/routes"; import { errorToastParams, successToastParams } from "~/features/common/toast"; import { useCreatePropertyMutation } from "~/features/properties/property.slice"; diff --git a/clients/admin-ui/src/types/api/models/Property.ts b/clients/admin-ui/src/types/api/models/Property.ts index f5b1f6d4f8..25a569d6ad 100644 --- a/clients/admin-ui/src/types/api/models/Property.ts +++ b/clients/admin-ui/src/types/api/models/Property.ts @@ -7,6 +7,6 @@ import { PropertyType } from "./PropertyType"; export type Property = { id: string; name: string; - type: string; - experiences?: Array; + type: PropertyType; + experiences: Array; }; From b5e3e18641e013aebf191553d7517ed5c483f5bc Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Mon, 4 Mar 2024 09:28:28 -0800 Subject: [PATCH 03/22] Linking properties and privacy experiences in the database --- .../features/properties/PropertiesTable.tsx | 2 +- .../src/features/properties/PropertyForm.tsx | 18 +++-- .../src/pages/consent/properties/[id].tsx | 4 +- clients/admin-ui/src/types/api/index.ts | 1 + .../api/models/MinimalPrivacyExperience.ts | 8 +++ .../admin-ui/src/types/api/models/Property.ts | 3 +- ...5b0db5b14_drop_key_column_from_property.py | 4 +- ...dd_plus_privacy_experience_config_table.py | 72 +++++++++++++++++++ src/fides/api/db/base.py | 2 +- src/fides/api/models/privacy_experience.py | 8 +++ src/fides/api/models/property.py | 52 +++++++++++++- src/fides/api/schemas/property.py | 13 +++- tests/ops/models/test_property.py | 35 +++++++++ 13 files changed, 206 insertions(+), 16 deletions(-) create mode 100644 clients/admin-ui/src/types/api/models/MinimalPrivacyExperience.ts create mode 100644 src/fides/api/alembic/migrations/versions/69e51a460e66_add_plus_privacy_experience_config_table.py diff --git a/clients/admin-ui/src/features/properties/PropertiesTable.tsx b/clients/admin-ui/src/features/properties/PropertiesTable.tsx index 115989dd52..6fa9a55ca9 100644 --- a/clients/admin-ui/src/features/properties/PropertiesTable.tsx +++ b/clients/admin-ui/src/features/properties/PropertiesTable.tsx @@ -125,7 +125,7 @@ export const PropertiesTable = () => { cell: (props) => ( exp.name)} {...props} /> ), diff --git a/clients/admin-ui/src/features/properties/PropertyForm.tsx b/clients/admin-ui/src/features/properties/PropertyForm.tsx index 04478b81d8..cbaffa63c7 100644 --- a/clients/admin-ui/src/features/properties/PropertyForm.tsx +++ b/clients/admin-ui/src/features/properties/PropertyForm.tsx @@ -3,8 +3,8 @@ import { Form, Formik, useFormikContext } from "formik"; import { useRouter } from "next/router"; import { useMemo } from "react"; +import { useAppSelector } from "~/app/hooks"; import FormSection from "~/features/common/form/FormSection"; -import { Property, PropertyType } from "~/types/api"; import { CustomClipboardCopy, CustomSelect, @@ -14,12 +14,12 @@ import { enumToOptions } from "~/features/common/helpers"; import { PROPERTIES_ROUTE } from "~/features/common/nav/v2/routes"; import ScrollableList from "~/features/common/ScrollableList"; import { - useGetAllExperienceConfigsQuery, selectAllExperienceConfigs, selectPage, selectPageSize, + useGetAllExperienceConfigsQuery, } from "~/features/privacy-experience/privacy-experience.slice"; -import { useAppSelector } from "~/app/hooks"; +import { MinimalPrivacyExperience, Property, PropertyType } from "~/types/api"; interface Props { property?: Property; @@ -27,9 +27,10 @@ interface Props { } export interface FormValues { + id: string; name: string; type: string; - experiences: Array; + experiences: Array; } const ExperiencesFormSection = () => { @@ -45,12 +46,15 @@ const ExperiencesFormSection = () => { return ( exp.name)} + idField="id" + nameField="name" + allItems={experienceConfigs.map((exp) => ({ + id: exp.id, + name: exp.name, + }))} values={values.experiences ?? []} setValues={(newValues) => setFieldValue("experiences", newValues)} - getItemLabel={(item) => item} draggable /> diff --git a/clients/admin-ui/src/pages/consent/properties/[id].tsx b/clients/admin-ui/src/pages/consent/properties/[id].tsx index 22c9beb9b5..25dbabccd4 100644 --- a/clients/admin-ui/src/pages/consent/properties/[id].tsx +++ b/clients/admin-ui/src/pages/consent/properties/[id].tsx @@ -16,8 +16,8 @@ import { isErrorResult } from "~/types/errors"; const EditPropertyPage: NextPage = () => { const toast = useToast(); const router = useRouter(); - const { id } = router.query; - const { data } = useGetPropertyByIdQuery(id as string); + const { id: propertyId } = router.query; + const { data } = useGetPropertyByIdQuery(propertyId as string); const [updateProperty] = useUpdatePropertyMutation(); const handleSubmit = async (values: FormValues) => { diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index 7aa373d914..fd5e2a5908 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -40,6 +40,7 @@ export type { Classification } from "./models/Classification"; export type { ClassificationResponse } from "./models/ClassificationResponse"; export { ClassificationStatus } from "./models/ClassificationStatus"; export type { ClassifyCollection } from "./models/ClassifyCollection"; +export type { MinimalPrivacyExperience } from "./models/MinimalPrivacyExperience"; export type { ClassifyDataFlow } from "./models/ClassifyDataFlow"; export type { ClassifyDataset } from "./models/ClassifyDataset"; export type { ClassifyDatasetResponse } from "./models/ClassifyDatasetResponse"; diff --git a/clients/admin-ui/src/types/api/models/MinimalPrivacyExperience.ts b/clients/admin-ui/src/types/api/models/MinimalPrivacyExperience.ts new file mode 100644 index 0000000000..578ea938ec --- /dev/null +++ b/clients/admin-ui/src/types/api/models/MinimalPrivacyExperience.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type MinimalPrivacyExperience = { + id: string; + name: string; +}; diff --git a/clients/admin-ui/src/types/api/models/Property.ts b/clients/admin-ui/src/types/api/models/Property.ts index 25a569d6ad..8b8b46bbbe 100644 --- a/clients/admin-ui/src/types/api/models/Property.ts +++ b/clients/admin-ui/src/types/api/models/Property.ts @@ -2,11 +2,12 @@ /* tslint:disable */ /* eslint-disable */ +import { MinimalPrivacyExperience } from "./MinimalPrivacyExperience"; import { PropertyType } from "./PropertyType"; export type Property = { id: string; name: string; type: PropertyType; - experiences: Array; + experiences: Array; }; diff --git a/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py b/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py index 9e20b668d4..ebe8330100 100644 --- a/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py +++ b/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py @@ -1,7 +1,7 @@ """drop key column from property Revision ID: 3815b0db5b14 -Revises: a1e23b70f2b2 +Revises: 0c65325843bd Create Date: 2024-02-29 21:54:38.751678 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "3815b0db5b14" -down_revision = "a1e23b70f2b2" +down_revision = "0c65325843bd" branch_labels = None depends_on = None diff --git a/src/fides/api/alembic/migrations/versions/69e51a460e66_add_plus_privacy_experience_config_table.py b/src/fides/api/alembic/migrations/versions/69e51a460e66_add_plus_privacy_experience_config_table.py new file mode 100644 index 0000000000..90b71b125c --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/69e51a460e66_add_plus_privacy_experience_config_table.py @@ -0,0 +1,72 @@ +"""add plus_privacy_experience_config table + +Revision ID: 69e51a460e66 +Revises: 3815b0db5b14 +Create Date: 2024-03-01 22:47:59.867223 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "69e51a460e66" +down_revision = "3815b0db5b14" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "plus_privacy_experience_config_property", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("id", sa.String(length=255), nullable=True), + sa.Column("privacy_experience_config_id", sa.String(), nullable=False), + sa.Column("property_id", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["privacy_experience_config_id"], + ["privacyexperienceconfig.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["property_id"], ["plus_property.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("privacy_experience_config_id", "property_id"), + ) + op.create_index( + op.f("ix_plus_privacy_experience_config_property_privacy_experience_config_id"), + "plus_privacy_experience_config_property", + ["privacy_experience_config_id"], + unique=False, + ) + op.create_index( + op.f("ix_plus_privacy_experience_config_property_property_id"), + "plus_privacy_experience_config_property", + ["property_id"], + unique=False, + ) + op.drop_index("ix_plus_property_id", table_name="plus_property") + + +def downgrade(): + op.create_index("ix_plus_property_id", "plus_property", ["id"], unique=False) + op.drop_index( + op.f("ix_plus_privacy_experience_config_property_property_id"), + table_name="plus_privacy_experience_config_property", + ) + op.drop_index( + op.f("ix_plus_privacy_experience_config_property_privacy_experience_config_id"), + table_name="plus_privacy_experience_config_property", + ) + op.drop_table("plus_privacy_experience_config_property") diff --git a/src/fides/api/db/base.py b/src/fides/api/db/base.py index bc349c0c4f..c0d7b96a6e 100644 --- a/src/fides/api/db/base.py +++ b/src/fides/api/db/base.py @@ -43,7 +43,7 @@ ServedNoticeHistory, ) from fides.api.models.privacy_request import PrivacyRequest -from fides.api.models.property import Property +from fides.api.models.property import PrivacyExperienceConfigProperty, Property from fides.api.models.registration import UserRegistration from fides.api.models.storage import StorageConfig from fides.api.models.system_compass_sync import SystemCompassSync diff --git a/src/fides/api/models/privacy_experience.py b/src/fides/api/models/privacy_experience.py index 50befa86f0..be5e000c1e 100644 --- a/src/fides/api/models/privacy_experience.py +++ b/src/fides/api/models/privacy_experience.py @@ -20,6 +20,7 @@ ) from fides.api.models.location_regulation_selections import PrivacyNoticeRegion from fides.api.models.privacy_notice import PrivacyNotice +from fides.api.models.property import Property from fides.api.schemas.language import SupportedLanguage @@ -229,6 +230,13 @@ class PrivacyExperienceConfig( order_by="ExperienceTranslation.created_at", ) + properties: RelationshipProperty[List[Property]] = relationship( + "Property", + secondary="plus_privacy_experience_config_property", + back_populates="experiences", + lazy="selectin", + ) + @property def regions(self) -> List[PrivacyNoticeRegion]: """Return the regions using this experience config""" diff --git a/src/fides/api/models/property.py b/src/fides/api/models/property.py index 27215b136c..b47057ff09 100644 --- a/src/fides/api/models/property.py +++ b/src/fides/api/models/property.py @@ -2,15 +2,21 @@ import random import string +from typing import TYPE_CHECKING, List +from uuid import uuid4 -from sqlalchemy import Column, String +from sqlalchemy import Column, ForeignKey, String from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import RelationshipProperty, relationship from fides.api.db.base_class import Base from fides.api.db.util import EnumColumn from fides.api.schemas.property import PropertyType from fides.config import get_config +if TYPE_CHECKING: + from fides.api.models.privacy_experience import PrivacyExperienceConfig + CONFIG = get_config() @@ -37,3 +43,47 @@ def __tablename__(self) -> str: ) name = Column(String, nullable=False, unique=True) type = Column(EnumColumn(PropertyType), nullable=False) + + experiences: RelationshipProperty[List[PrivacyExperienceConfig]] = relationship( + "PrivacyExperienceConfig", + secondary="plus_privacy_experience_config_property", + back_populates="properties", + lazy="selectin", + ) + + +class PrivacyExperienceConfigProperty(Base): + @declared_attr + def __tablename__(self) -> str: + return "plus_privacy_experience_config_property" + + def generate_uuid(self) -> str: + """ + Generates a uuid with a prefix based on the tablename to be used as the + record's ID value + """ + try: + prefix = f"{self.current_column.table.name[:3]}_" # type: ignore + except AttributeError: + prefix = "" + uuid = str(uuid4()) + return f"{prefix}{uuid}" + + # Overrides Base.id so this is not a primary key. + # Instead, we have a composite PK of privacy_experience_config_id and property_id + id = Column(String(255), default=generate_uuid) + + privacy_experience_config_id = Column( + String, + ForeignKey("privacyexperienceconfig.id", ondelete="CASCADE"), + index=True, + nullable=False, + primary_key=True, + ) + property_id = Column( + String, + ForeignKey("plus_property.id", ondelete="CASCADE"), + index=True, + nullable=False, + primary_key=True, + ) diff --git a/src/fides/api/schemas/property.py b/src/fides/api/schemas/property.py index 9a38fe9391..e4177483ba 100644 --- a/src/fides/api/schemas/property.py +++ b/src/fides/api/schemas/property.py @@ -1,9 +1,19 @@ from enum import Enum -from typing import Optional +from typing import List, Optional from fides.api.schemas.base_class import FidesSchema +class MinimalPrivacyExperience(FidesSchema): + """ + Minimal representation of a privacy experience, contains enough information + to select experiences by name in the UI and an ID to link the selections in the database. + """ + + id: str + name: str + + class PropertyType(Enum): website = "Website" other = "Other" @@ -13,3 +23,4 @@ class Property(FidesSchema): name: str type: PropertyType id: Optional[str] = None + experiences: Optional[List[MinimalPrivacyExperience]] = None diff --git a/tests/ops/models/test_property.py b/tests/ops/models/test_property.py index b5700836b5..9041ee9594 100644 --- a/tests/ops/models/test_property.py +++ b/tests/ops/models/test_property.py @@ -26,6 +26,7 @@ def test_create_property(self, db): assert prop.name == "New Property" assert prop.type == PropertyType.website assert prop.id.startswith("FDS") + assert prop.experiences == [] def test_create_property_with_special_characters(self, db): prop = Property.create( @@ -37,6 +38,7 @@ def test_create_property_with_special_characters(self, db): assert prop.name == "New Property (Prod)" assert prop.type == PropertyType.website assert prop.id.startswith("FDS") + assert prop.experiences == [] def test_update_property(self, db: Session, property_a): property_a.name = "Property B" @@ -47,3 +49,36 @@ def test_update_property(self, db: Session, property_a): assert updated_property.name == "Property B" assert updated_property.type == PropertyType.other assert updated_property.id.startswith("FDS") + assert updated_property.experiences == [] + + +class TestPrivacyExperienceConfigProperty: + @pytest.fixture + def property_a(self, db) -> Generator: + prop_a = Property.create( + db=db, + data=PropertySchema(name="New Property", type=PropertyType.website).dict(), + ) + yield prop_a + prop_a.delete(db=db) + + def test_link_property_and_experience( + self, db: Session, property_a, privacy_experience_privacy_center + ): + experience_config = privacy_experience_privacy_center.experience_config + experience_config.name = "Privacy Center" + experience_config.save(db=db) + + property_a.experiences.append( + privacy_experience_privacy_center.experience_config + ) + property_a.save(db=db) + + updated_property = Property.get_by(db=db, field="id", value=property_a.id) + assert updated_property.name == "New Property" + assert updated_property.type == PropertyType.website + assert updated_property.id.startswith("FDS") + assert len(updated_property.experiences) == 1 + + experience = updated_property.experiences[0] + assert experience.name == "Privacy Center" From cdd7e3dc002e7000e52a885dea7da57c5924f3ef Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Tue, 5 Mar 2024 14:02:36 -0800 Subject: [PATCH 04/22] Bidirectional linking of properties and privacy experiences --- clients/admin-ui/src/app/store.ts | 2 + .../PrivacyExperienceForm.tsx | 24 ++++ .../privacy-experience.slice.ts | 5 +- .../features/properties/PropertiesTable.tsx | 12 +- .../features/properties/PropertyActions.tsx | 7 +- .../src/features/properties/PropertyForm.tsx | 126 ++++++++++-------- .../admin-ui/src/features/properties/index.ts | 1 + .../src/features/properties/property.slice.ts | 68 ++++++++-- .../src/pages/consent/properties/[id].tsx | 2 +- .../api/models/ExperienceConfigCreate.ts | 2 + src/fides/api/models/privacy_experience.py | 25 ++++ src/fides/api/models/property.py | 57 +++++++- src/fides/api/schemas/property.py | 5 + tests/ops/models/test_property.py | 73 +++++----- 14 files changed, 285 insertions(+), 124 deletions(-) create mode 100644 clients/admin-ui/src/features/properties/index.ts diff --git a/clients/admin-ui/src/app/store.ts b/clients/admin-ui/src/app/store.ts index 1d1b838a8f..625f477643 100644 --- a/clients/admin-ui/src/app/store.ts +++ b/clients/admin-ui/src/app/store.ts @@ -36,6 +36,7 @@ import { languageSlice } from "~/features/privacy-experience/language.slice"; import { privacyExperienceConfigSlice } from "~/features/privacy-experience/privacy-experience.slice"; import { privacyNoticesSlice } from "~/features/privacy-notices/privacy-notices.slice"; import { subjectRequestsSlice } from "~/features/privacy-requests"; +import { propertySlice } from "~/features/properties"; import { systemSlice } from "~/features/system"; import { dictSuggestionsSlice } from "~/features/system/dictionary-form/dict-suggestion.slice"; import { taxonomySlice } from "~/features/taxonomy"; @@ -84,6 +85,7 @@ const reducer = { [organizationSlice.name]: organizationSlice.reducer, [privacyNoticesSlice.name]: privacyNoticesSlice.reducer, [privacyExperienceConfigSlice.name]: privacyExperienceConfigSlice.reducer, + [propertySlice.name]: propertySlice.reducer, [subjectRequestsSlice.name]: subjectRequestsSlice.reducer, [systemSlice.name]: systemSlice.reducer, [taxonomySlice.name]: taxonomySlice.reducer, diff --git a/clients/admin-ui/src/features/privacy-experience/PrivacyExperienceForm.tsx b/clients/admin-ui/src/features/privacy-experience/PrivacyExperienceForm.tsx index e56f68da9f..15011009d1 100644 --- a/clients/admin-ui/src/features/privacy-experience/PrivacyExperienceForm.tsx +++ b/clients/admin-ui/src/features/privacy-experience/PrivacyExperienceForm.tsx @@ -30,6 +30,12 @@ import { selectPageSize as selectNoticePageSize, useGetAllPrivacyNoticesQuery, } from "~/features/privacy-notices/privacy-notices.slice"; +import { + selectAllProperties, + selectPage as selectPropertyPage, + selectPageSize as selectPropertyPageSize, + useGetAllPropertiesQuery, +} from "~/features/properties/property.slice"; import { ComponentType, ExperienceConfigCreate, @@ -102,6 +108,11 @@ export const PrivacyExperienceForm = ({ return `${name}${t.is_default ? " (Default)" : ""}`; }; + const propertyPage = useAppSelector(selectPropertyPage); + const propertyPageSize = useAppSelector(selectPropertyPageSize); + useGetAllPropertiesQuery({ page: propertyPage, size: propertyPageSize }); + const allProperties = useAppSelector(selectAllProperties); + if (editingStyle) { return ( <> @@ -177,6 +188,19 @@ export const PrivacyExperienceForm = ({ > Customize appearance + ({ + id: property.id, + name: property.name, + }))} + values={values.properties ?? []} + setValues={(newValues) => setFieldValue("properties", newValues)} + draggable + /> Privacy notices diff --git a/clients/admin-ui/src/features/privacy-experience/privacy-experience.slice.ts b/clients/admin-ui/src/features/privacy-experience/privacy-experience.slice.ts index d799722d2c..1ada962c5c 100644 --- a/clients/admin-ui/src/features/privacy-experience/privacy-experience.slice.ts +++ b/clients/admin-ui/src/features/privacy-experience/privacy-experience.slice.ts @@ -84,7 +84,7 @@ const privacyExperienceConfigApi = baseApi.injectEndpoints({ body, }; }, - invalidatesTags: () => ["Privacy Experience Configs"], + invalidatesTags: () => ["Privacy Experience Configs", "Property"], }), limitedPatchExperienceConfig: build.mutation< ExperienceConfigResponse, @@ -114,7 +114,7 @@ const privacyExperienceConfigApi = baseApi.injectEndpoints({ url: `experience-config/`, body: payload, }), - invalidatesTags: () => ["Privacy Experience Configs"], + invalidatesTags: () => ["Privacy Experience Configs", "Property"], }), }), }); @@ -137,6 +137,7 @@ export const { reducer } = privacyExperienceConfigSlice; const selectPrivacyExperienceConfig = (state: RootState) => state.privacyExperienceConfig; + export const selectPage = createSelector( selectPrivacyExperienceConfig, (state) => state.page diff --git a/clients/admin-ui/src/features/properties/PropertiesTable.tsx b/clients/admin-ui/src/features/properties/PropertiesTable.tsx index 6fa9a55ca9..711f0f3617 100644 --- a/clients/admin-ui/src/features/properties/PropertiesTable.tsx +++ b/clients/admin-ui/src/features/properties/PropertiesTable.tsx @@ -23,7 +23,7 @@ import { useRouter } from "next/router"; import { useEffect, useMemo, useState } from "react"; import { useGetHealthQuery } from "~/features/plus/plus.slice"; -import { useGetPropertiesQuery } from "~/features/properties/property.slice"; +import { useGetAllPropertiesQuery } from "~/features/properties/property.slice"; import { Page_Property_, Property } from "~/types/api"; import { PROPERTIES_ROUTE } from "../common/nav/v2/routes"; @@ -92,9 +92,9 @@ export const PropertiesTable = () => { isFetching, isLoading, data: properties, - } = useGetPropertiesQuery({ - pageIndex, - pageSize, + } = useGetAllPropertiesQuery({ + page: pageIndex, + size: pageSize, search: globalFilter, }); @@ -120,12 +120,12 @@ export const PropertiesTable = () => { cell: (props) => , header: (props) => , }), - columnHelper.accessor((row) => row.experiences, { + columnHelper.accessor((row) => row.experiences.map((exp) => exp.name), { id: "experiences", cell: (props) => ( exp.name)} + value={props.getValue()} {...props} /> ), diff --git a/clients/admin-ui/src/features/properties/PropertyActions.tsx b/clients/admin-ui/src/features/properties/PropertyActions.tsx index 6c58e91250..0ffdbba9ba 100644 --- a/clients/admin-ui/src/features/properties/PropertyActions.tsx +++ b/clients/admin-ui/src/features/properties/PropertyActions.tsx @@ -15,7 +15,7 @@ const PropertyActions = ({ property }: Props) => { const router = useRouter(); const handleEdit = () => { - router.push(`${PROPERTIES_ROUTE}/${property.key}`); + router.push(`${PROPERTIES_ROUTE}/${property.id}`); }; return ( @@ -27,7 +27,10 @@ const PropertyActions = ({ property }: Props) => { size="xs" marginRight="10px" icon={} - onClick={handleEdit} + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + handleEdit(); + }} /> ); diff --git a/clients/admin-ui/src/features/properties/PropertyForm.tsx b/clients/admin-ui/src/features/properties/PropertyForm.tsx index cbaffa63c7..2877d364eb 100644 --- a/clients/admin-ui/src/features/properties/PropertyForm.tsx +++ b/clients/admin-ui/src/features/properties/PropertyForm.tsx @@ -27,9 +27,9 @@ interface Props { } export interface FormValues { - id: string; + id?: string; name: string; - type: string; + type: PropertyType; experiences: Array; } @@ -69,7 +69,12 @@ const PropertyForm = ({ property, handleSubmit }: Props) => { }; const initialValues = useMemo( - () => property || { name: "", type: "", experiences: [] }, + () => + property || { + name: "", + type: PropertyType.WEBSITE, + experiences: [], + }, [property] ); @@ -79,67 +84,70 @@ const PropertyForm = ({ property, handleSubmit }: Props) => { initialValues={initialValues} onSubmit={handleSubmit} > -
- - - - - - - - - - {property && ( + {({ dirty, isValid, isSubmitting }) => ( + - - + + - )} - - - - -
+ + + + {property && ( + + + + + + )} + + + + + + )}
); }; diff --git a/clients/admin-ui/src/features/properties/index.ts b/clients/admin-ui/src/features/properties/index.ts new file mode 100644 index 0000000000..e1334f0a0e --- /dev/null +++ b/clients/admin-ui/src/features/properties/index.ts @@ -0,0 +1 @@ +export * from "./property.slice"; diff --git a/clients/admin-ui/src/features/properties/property.slice.ts b/clients/admin-ui/src/features/properties/property.slice.ts index 7b152bc55a..956f193d85 100644 --- a/clients/admin-ui/src/features/properties/property.slice.ts +++ b/clients/admin-ui/src/features/properties/property.slice.ts @@ -1,20 +1,31 @@ +import { createSelector, createSlice } from "@reduxjs/toolkit"; + +import type { RootState } from "~/app/store"; +import { baseApi } from "~/features/common/api.slice"; import { Page_Property_, Property } from "~/types/api"; -import { baseApi } from "../common/api.slice"; +export interface State { + page?: number; + pageSize?: number; +} + +const initialState: State = { + page: 1, + pageSize: 50, +}; + +interface PropertyParams { + search?: string; + page?: number; + size?: number; +} export const propertiesApi = baseApi.injectEndpoints({ endpoints: (builder) => ({ - getProperties: builder.query< - Page_Property_, - { pageIndex: number; pageSize: number; search?: string } - >({ + getAllProperties: builder.query({ query: (params) => ({ url: `plus/properties`, - params: { - page: params.pageIndex, - size: params.pageSize, - search: params.search, - }, + params, }), providesTags: ["Property"], }), @@ -34,21 +45,52 @@ export const propertiesApi = baseApi.injectEndpoints({ }), updateProperty: builder.mutation< Property, - { key: string; property: Partial } + { id: string; property: Partial } >({ query: ({ id, property }) => ({ url: `plus/property/${id}`, method: "PUT", body: property, }), - invalidatesTags: ["Property"], + invalidatesTags: ["Property", "Privacy Experience Configs"], }), }), }); export const { - useGetPropertiesQuery, + useGetAllPropertiesQuery, useGetPropertyByIdQuery, useCreatePropertyMutation, useUpdatePropertyMutation, } = propertiesApi; + +export const propertySlice = createSlice({ + name: "properties", + initialState, + reducers: {}, +}); +export const { reducer } = propertySlice; + +const selectProperties = (state: RootState) => state.properties; + +export const selectPage = createSelector( + selectProperties, + (state) => state.page +); + +export const selectPageSize = createSelector( + selectProperties, + (state) => state.pageSize +); + +const emptyProperties: Property[] = []; +export const selectAllProperties = createSelector( + [(RootState) => RootState, selectPage, selectPageSize], + (RootState, page, pageSize) => { + const data = propertiesApi.endpoints.getAllProperties.select({ + page, + size: pageSize, + })(RootState)?.data; + return data ? data.items : emptyProperties; + } +); diff --git a/clients/admin-ui/src/pages/consent/properties/[id].tsx b/clients/admin-ui/src/pages/consent/properties/[id].tsx index 25dbabccd4..809b521f92 100644 --- a/clients/admin-ui/src/pages/consent/properties/[id].tsx +++ b/clients/admin-ui/src/pages/consent/properties/[id].tsx @@ -23,7 +23,7 @@ const EditPropertyPage: NextPage = () => { const handleSubmit = async (values: FormValues) => { const { id, ...updateValues } = values; - const result = await updateProperty({ id, property: updateValues }); + const result = await updateProperty({ id: id!, property: updateValues }); if (isErrorResult(result)) { toast(errorToastParams(getErrorMessage(result.error))); diff --git a/clients/admin-ui/src/types/api/models/ExperienceConfigCreate.ts b/clients/admin-ui/src/types/api/models/ExperienceConfigCreate.ts index 3fc01c9b59..2185600159 100644 --- a/clients/admin-ui/src/types/api/models/ExperienceConfigCreate.ts +++ b/clients/admin-ui/src/types/api/models/ExperienceConfigCreate.ts @@ -5,6 +5,7 @@ import type { ComponentType } from "./ComponentType"; import type { ExperienceTranslationCreate } from "./ExperienceTranslationCreate"; import type { PrivacyNoticeRegion } from "./PrivacyNoticeRegion"; +import type { Property } from "./Property"; /** * Schema for creating Experience Configs via the API @@ -20,4 +21,5 @@ export type ExperienceConfigCreate = { component: ComponentType; privacy_notice_ids?: Array; translations?: Array; + properties?: Array; }; diff --git a/src/fides/api/models/privacy_experience.py b/src/fides/api/models/privacy_experience.py index be5e000c1e..ba3db9a06f 100644 --- a/src/fides/api/models/privacy_experience.py +++ b/src/fides/api/models/privacy_experience.py @@ -278,6 +278,7 @@ def create( translations = data.pop("translations", []) regions = data.pop("regions", []) privacy_notice_ids = data.pop("privacy_notice_ids", []) + properties = data.pop("properties", []) data.pop( "id", None ) # Default templates may have ids but we don't want to use them here @@ -305,6 +306,8 @@ def create( link_notices_to_experience_config( db, notice_ids=privacy_notice_ids, experience_config=experience_config ) + # Link Properties to this Privacy Experience config via the PrivacyExperienceConfigProperty table + link_properties_to_experience_config(db, properties, experience_config) return experience_config @@ -323,6 +326,7 @@ def update(self, db: Session, *, data: dict[str, Any]) -> PrivacyExperienceConfi request_translations = data.pop("translations", []) regions = data.pop("regions", []) privacy_notice_ids = data.pop("privacy_notice_ids", []) + properties = data.pop("properties", []) # Do a patch update of the existing privacy experience config if applicable config_updated = update_if_modified(self, db=db, data=data) @@ -368,6 +372,8 @@ def update(self, db: Session, *, data: dict[str, Any]) -> PrivacyExperienceConfi link_notices_to_experience_config( db, notice_ids=privacy_notice_ids, experience_config=self ) + # Link Properties to this Privacy Experience config via the PrivacyExperienceConfigProperty table + link_properties_to_experience_config(db, properties, self) return self # type: ignore[return-value] @@ -387,6 +393,7 @@ def dry_update(self, *, data: dict[str, Any]) -> PrivacyExperienceConfig: # to prevent the ExperienceConfig "dry_update" from being added to Session.new # (which would cause another PrivacyExperienceConfig to be created!) updated_attributes.pop("privacy_notices", []) + updated_attributes.pop("properties", []) return PrivacyExperienceConfig(**updated_attributes) @@ -656,6 +663,24 @@ def link_notices_to_experience_config( return experience_config.privacy_notices +def link_properties_to_experience_config( + db: Session, + properties: List[Dict[str, Any]], + experience_config: PrivacyExperienceConfig, +) -> List[Property]: + """ + Link supplied properties to ExperienceConfig and unlink any properties not supplied. + """ + new_properties = ( + db.query(Property) + .filter(Property.id.in_([prop["id"] for prop in properties])) + .all() + ) + experience_config.properties = new_properties + experience_config.save(db) + return experience_config.properties + + def create_historical_record_for_config_and_translation( db: Session, privacy_experience_config: PrivacyExperienceConfig, diff --git a/src/fides/api/models/property.py b/src/fides/api/models/property.py index b47057ff09..be8506cdfc 100644 --- a/src/fides/api/models/property.py +++ b/src/fides/api/models/property.py @@ -2,12 +2,12 @@ import random import string -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type from uuid import uuid4 from sqlalchemy import Column, ForeignKey, String from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm import RelationshipProperty, relationship +from sqlalchemy.orm import RelationshipProperty, Session, relationship from fides.api.db.base_class import Base from fides.api.db.util import EnumColumn @@ -51,6 +51,29 @@ def __tablename__(self) -> str: lazy="selectin", ) + @classmethod + def create( + cls: Type[Property], + db: Session, + *, + data: dict[str, Any], + check_name: bool = False, + ) -> Property: + experiences = data.pop("experiences", []) + prop: Property = super().create(db=db, data=data, check_name=check_name) + link_experience_configs_to_property( + db, experience_configs=experiences, prop=prop + ) + return prop + + def update(self, db: Session, *, data: Dict[str, Any]) -> Property: + experiences = data.pop("experiences", []) + super().update(db=db, data=data) + link_experience_configs_to_property( + db, experience_configs=experiences, prop=self + ) + return self + class PrivacyExperienceConfigProperty(Base): @declared_attr @@ -87,3 +110,33 @@ def generate_uuid(self) -> str: nullable=False, primary_key=True, ) + + +def link_experience_configs_to_property( + db: Session, + *, + experience_configs: Optional[List[Dict[str, Any]]] = None, + prop: Property, +) -> List[PrivacyExperienceConfig]: + """ + Link supplied experience configs to the property. + """ + # delayed import to avoid circular declarations + from fides.api.models.privacy_experience import PrivacyExperienceConfig + + if experience_configs is None: + return [] + + new_experience_configs = ( + db.query(PrivacyExperienceConfig) + .filter( + PrivacyExperienceConfig.id.in_( + [experience_config["id"] for experience_config in experience_configs] + ) + ) + .all() + ) + + prop.experiences = new_experience_configs + prop.save(db) + return prop.experiences diff --git a/src/fides/api/schemas/property.py b/src/fides/api/schemas/property.py index e4177483ba..b0079a16b4 100644 --- a/src/fides/api/schemas/property.py +++ b/src/fides/api/schemas/property.py @@ -19,6 +19,11 @@ class PropertyType(Enum): other = "Other" +class MinimalProperty(FidesSchema): + id: str + name: str + + class Property(FidesSchema): name: str type: PropertyType diff --git a/tests/ops/models/test_property.py b/tests/ops/models/test_property.py index 9041ee9594..ac9631a3d6 100644 --- a/tests/ops/models/test_property.py +++ b/tests/ops/models/test_property.py @@ -1,15 +1,16 @@ -from typing import Generator +from typing import Any, Dict, Generator import pytest from sqlalchemy.orm import Session +from fides.api.models.privacy_experience import PrivacyExperienceConfig from fides.api.models.property import Property from fides.api.schemas.property import Property as PropertySchema from fides.api.schemas.property import PropertyType class TestProperty: - @pytest.fixture + @pytest.fixture(scope="function") def property_a(self, db) -> Generator: prop_a = Property.create( db=db, @@ -18,15 +19,32 @@ def property_a(self, db) -> Generator: yield prop_a prop_a.delete(db=db) - def test_create_property(self, db): + @pytest.fixture(scope="function") + def minimal_experience( + self, db, privacy_experience_privacy_center + ) -> Dict[str, Any]: + experience_config = privacy_experience_privacy_center.experience_config + experience_config.name = "Privacy Center" + experience_config.save(db=db) + return {"id": experience_config.id, "name": experience_config.name} + + def test_create_property(self, db, minimal_experience): prop = Property.create( db=db, - data=PropertySchema(name="New Property", type=PropertyType.website).dict(), + data=PropertySchema( + name="New Property", + type=PropertyType.website, + experiences=[minimal_experience], + ).dict(), ) assert prop.name == "New Property" assert prop.type == PropertyType.website assert prop.id.startswith("FDS") - assert prop.experiences == [] + assert len(prop.experiences) == 1 + + experience = prop.experiences[0] + assert experience.id == minimal_experience["id"] + assert experience.name == minimal_experience["name"] def test_create_property_with_special_characters(self, db): prop = Property.create( @@ -40,45 +58,22 @@ def test_create_property_with_special_characters(self, db): assert prop.id.startswith("FDS") assert prop.experiences == [] - def test_update_property(self, db: Session, property_a): - property_a.name = "Property B" - property_a.type = PropertyType.other - property_a.save(db=db) - - updated_property = Property.get_by(db=db, field="id", value=property_a.id) - assert updated_property.name == "Property B" - assert updated_property.type == PropertyType.other - assert updated_property.id.startswith("FDS") - assert updated_property.experiences == [] - - -class TestPrivacyExperienceConfigProperty: - @pytest.fixture - def property_a(self, db) -> Generator: - prop_a = Property.create( + def test_update_property(self, db: Session, property_a, minimal_experience): + property_a.update( db=db, - data=PropertySchema(name="New Property", type=PropertyType.website).dict(), - ) - yield prop_a - prop_a.delete(db=db) - - def test_link_property_and_experience( - self, db: Session, property_a, privacy_experience_privacy_center - ): - experience_config = privacy_experience_privacy_center.experience_config - experience_config.name = "Privacy Center" - experience_config.save(db=db) - - property_a.experiences.append( - privacy_experience_privacy_center.experience_config + data={ + "name": "Property B", + "type": PropertyType.other, + "experiences": [minimal_experience], + }, ) - property_a.save(db=db) updated_property = Property.get_by(db=db, field="id", value=property_a.id) - assert updated_property.name == "New Property" - assert updated_property.type == PropertyType.website + assert updated_property.name == "Property B" + assert updated_property.type == PropertyType.other assert updated_property.id.startswith("FDS") assert len(updated_property.experiences) == 1 experience = updated_property.experiences[0] - assert experience.name == "Privacy Center" + assert experience.id == minimal_experience["id"] + assert experience.name == minimal_experience["name"] From 00d9335555ea1df57c826fd772c857f378438675 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Tue, 5 Mar 2024 15:22:27 -0800 Subject: [PATCH 05/22] Removing properties from history data --- src/fides/api/models/privacy_experience.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/fides/api/models/privacy_experience.py b/src/fides/api/models/privacy_experience.py index ba3db9a06f..1d65c55e2b 100644 --- a/src/fides/api/models/privacy_experience.py +++ b/src/fides/api/models/privacy_experience.py @@ -696,6 +696,7 @@ def create_historical_record_for_config_and_translation( history_data: dict = create_historical_data_from_record(privacy_experience_config) history_data.pop("privacy_notices", None) history_data.pop("translations", None) + history_data.pop("properties", None) updated_translation_data: dict = create_historical_data_from_record( experience_translation ) From 1f87ea24bf73dda8718864f7cb67f4c4120b3068 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Tue, 5 Mar 2024 16:18:39 -0800 Subject: [PATCH 06/22] Misc fixes --- src/fides/api/models/property.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fides/api/models/property.py b/src/fides/api/models/property.py index be8506cdfc..b18c738f30 100644 --- a/src/fides/api/models/property.py +++ b/src/fides/api/models/property.py @@ -57,7 +57,7 @@ def create( db: Session, *, data: dict[str, Any], - check_name: bool = False, + check_name: bool = True, ) -> Property: experiences = data.pop("experiences", []) prop: Property = super().create(db=db, data=data, check_name=check_name) @@ -124,7 +124,7 @@ def link_experience_configs_to_property( # delayed import to avoid circular declarations from fides.api.models.privacy_experience import PrivacyExperienceConfig - if experience_configs is None: + if not experience_configs: return [] new_experience_configs = ( From c83829d96c77597c88d5a6b2c39b4ecbb1cdd33e Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Wed, 6 Mar 2024 08:41:47 -0800 Subject: [PATCH 07/22] Fixing downrev --- .../versions/3815b0db5b14_drop_key_column_from_property.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py b/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py index ebe8330100..ec7eecc939 100644 --- a/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py +++ b/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py @@ -1,7 +1,7 @@ """drop key column from property Revision ID: 3815b0db5b14 -Revises: 0c65325843bd +Revises: 14acee6f5459 Create Date: 2024-02-29 21:54:38.751678 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "3815b0db5b14" -down_revision = "0c65325843bd" +down_revision = "14acee6f5459" branch_labels = None depends_on = None From 34bf8be9bfad29293eca70241619feba92ef321b Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Wed, 6 Mar 2024 10:52:17 -0800 Subject: [PATCH 08/22] Updating Cypress fixtures --- .../admin-ui/cypress/fixtures/properties/properties.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/clients/admin-ui/cypress/fixtures/properties/properties.json b/clients/admin-ui/cypress/fixtures/properties/properties.json index ea5696d158..c9c2621053 100644 --- a/clients/admin-ui/cypress/fixtures/properties/properties.json +++ b/clients/admin-ui/cypress/fixtures/properties/properties.json @@ -3,12 +3,14 @@ { "name": "Property A", "type": "Website", - "key": "property_a" + "id": "FDS-CEA9EV", + "experiences": [{ "id": 1, "name": "US Privacy Center" }] }, { "name": "Property B", - "type": "Other", - "key": "property_b" + "type": "Website", + "id": "FDS-Z21I5X", + "experiences": [{ "id": 1, "name": "US Privacy Center" }] } ], "total": 2, From 300545fefdb99633186226248cc8e5cb28f4b0f0 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Wed, 6 Mar 2024 11:21:14 -0800 Subject: [PATCH 09/22] Updating fides dataset --- .fides/db_dataset.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index c28e938285..7038d52f6c 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -2047,3 +2047,15 @@ dataset: data_categories: [system.operations] - name: updated_at data_categories: [system.operations] + - name: plus_privacy_experience_config_property + fields: + - name: id + data_categories: [system.operations] + - name: privacy_experience_config_id + data_categories: [system.operations] + - name: property_id + data_categories: [system.operations] + - name: created_at + data_categories: [system.operations] + - name: updated_at + data_categories: [system.operations] From bf3420b0da010673b0284c776a440162c89d250f Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Wed, 6 Mar 2024 13:10:19 -0800 Subject: [PATCH 10/22] Fixing downrev --- .../versions/3815b0db5b14_drop_key_column_from_property.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py b/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py index ec7eecc939..16388dbe29 100644 --- a/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py +++ b/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "3815b0db5b14" -down_revision = "14acee6f5459" +down_revision = "32497f08e227" branch_labels = None depends_on = None From 23aededdbea61668ef4203a2ecd9ff2b9241b14a Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Thu, 7 Mar 2024 09:58:23 -0800 Subject: [PATCH 11/22] Adding property column to experiences table --- .../PrivacyExperiencesTable.tsx | 20 +++++++++++++++++++ clients/admin-ui/src/types/api/index.ts | 3 ++- .../ExperienceConfigListViewResponse.ts | 2 ++ .../src/types/api/models/MinimalProperty.ts | 8 ++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 clients/admin-ui/src/types/api/models/MinimalProperty.ts diff --git a/clients/admin-ui/src/features/privacy-experience/PrivacyExperiencesTable.tsx b/clients/admin-ui/src/features/privacy-experience/PrivacyExperiencesTable.tsx index 9b364452c3..337fe3f5db 100644 --- a/clients/admin-ui/src/features/privacy-experience/PrivacyExperiencesTable.tsx +++ b/clients/admin-ui/src/features/privacy-experience/PrivacyExperiencesTable.tsx @@ -153,6 +153,26 @@ export const PrivacyExperiencesTable = () => { showHeaderMenu: true, }, }), + columnHelper.accessor( + (row) => row.properties.map((property) => property.name), + { + id: "properties", + cell: (props) => ( + + ), + header: (props) => ( + + ), + meta: { + displayText: "Properties", + showHeaderMenu: true, + }, + } + ), columnHelper.accessor((row) => row.updated_at, { id: "updated_at", cell: (props) => ( diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index fd5e2a5908..3945152c92 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -40,7 +40,6 @@ export type { Classification } from "./models/Classification"; export type { ClassificationResponse } from "./models/ClassificationResponse"; export { ClassificationStatus } from "./models/ClassificationStatus"; export type { ClassifyCollection } from "./models/ClassifyCollection"; -export type { MinimalPrivacyExperience } from "./models/MinimalPrivacyExperience"; export type { ClassifyDataFlow } from "./models/ClassifyDataFlow"; export type { ClassifyDataset } from "./models/ClassifyDataset"; export type { ClassifyDatasetResponse } from "./models/ClassifyDatasetResponse"; @@ -211,6 +210,8 @@ export type { MessagingServiceSecretsMailchimpTransactionalDocs } from "./models export { MessagingServiceType } from "./models/MessagingServiceType"; export type { MessagingTemplateRequest } from "./models/MessagingTemplateRequest"; export type { MessagingTemplateResponse } from "./models/MessagingTemplateResponse"; +export type { MinimalPrivacyExperience } from "./models/MinimalPrivacyExperience"; +export type { MinimalProperty } from "./models/MinimalProperty"; export type { MongoDBDocsSchema } from "./models/MongoDBDocsSchema"; export type { Property } from "./models/Property"; export type { Page_Property_ } from "./models/Page_Property_"; diff --git a/clients/admin-ui/src/types/api/models/ExperienceConfigListViewResponse.ts b/clients/admin-ui/src/types/api/models/ExperienceConfigListViewResponse.ts index 2fc34587b8..6edc825db5 100644 --- a/clients/admin-ui/src/types/api/models/ExperienceConfigListViewResponse.ts +++ b/clients/admin-ui/src/types/api/models/ExperienceConfigListViewResponse.ts @@ -3,6 +3,7 @@ /* eslint-disable */ import type { ComponentType } from "./ComponentType"; +import { MinimalProperty } from "./MinimalProperty"; import type { PrivacyNoticeRegion } from "./PrivacyNoticeRegion"; /** @@ -12,6 +13,7 @@ export type ExperienceConfigListViewResponse = { id: string; name?: string; regions: Array; + properties: Array; component: ComponentType; updated_at: string; disabled: boolean; diff --git a/clients/admin-ui/src/types/api/models/MinimalProperty.ts b/clients/admin-ui/src/types/api/models/MinimalProperty.ts new file mode 100644 index 0000000000..3408a59a48 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/MinimalProperty.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type MinimalProperty = { + id: string; + name: string; +}; From fb4bdf337c3708c2a187b364fa93ea6e65597419 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Thu, 7 Mar 2024 10:02:24 -0800 Subject: [PATCH 12/22] Resizing experience table buttons --- .../src/features/custom-assets/CustomAssetUploadButton.tsx | 2 +- .../admin-ui/src/features/privacy-experience/JavaScriptTag.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/clients/admin-ui/src/features/custom-assets/CustomAssetUploadButton.tsx b/clients/admin-ui/src/features/custom-assets/CustomAssetUploadButton.tsx index 03d43b0dd7..0dc2a2223d 100644 --- a/clients/admin-ui/src/features/custom-assets/CustomAssetUploadButton.tsx +++ b/clients/admin-ui/src/features/custom-assets/CustomAssetUploadButton.tsx @@ -18,7 +18,7 @@ const CustomAssetUploadButton: React.FC = ({ <> Date: Fri, 8 Mar 2024 08:47:33 -0800 Subject: [PATCH 20/22] Removing unused import --- src/fides/api/models/property.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fides/api/models/property.py b/src/fides/api/models/property.py index 4e567104f5..95cd2a3ad5 100644 --- a/src/fides/api/models/property.py +++ b/src/fides/api/models/property.py @@ -2,7 +2,7 @@ import random import string -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type +from typing import TYPE_CHECKING, Any, Dict, List, Type from uuid import uuid4 from sqlalchemy import Column, ForeignKey, String From f07df1d143c0240de38419e4d5595c3eea0540de Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Sun, 10 Mar 2024 20:15:53 -0700 Subject: [PATCH 21/22] Updating Fides JS script template --- .../src/features/privacy-experience/NewJavaScriptTag.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/privacy-experience/NewJavaScriptTag.tsx b/clients/admin-ui/src/features/privacy-experience/NewJavaScriptTag.tsx index 44d45a44cf..11b0c21399 100644 --- a/clients/admin-ui/src/features/privacy-experience/NewJavaScriptTag.tsx +++ b/clients/admin-ui/src/features/privacy-experience/NewJavaScriptTag.tsx @@ -21,7 +21,7 @@ import { Property } from "~/types/api"; const PRIVACY_CENTER_HOSTNAME_TEMPLATE = "{privacy-center-hostname-and-path}"; const PROPERTY_UNIQUE_ID_TEMPLATE = "{property-unique-id}"; -const FIDES_JS_SCRIPT_TEMPLATE = ``; +const FIDES_JS_SCRIPT_TEMPLATE = ``; const FIDES_GTM_SCRIPT_TAG = ""; interface Props { From 8b9bdbfcad8a4ac48980d72d1b2028d8b23eac2a Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Mon, 11 Mar 2024 12:16:33 -0700 Subject: [PATCH 22/22] Fixing migration comment and updating change log --- CHANGELOG.md | 3 +++ .../versions/3815b0db5b14_drop_key_column_from_property.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec214934b9..a9879405d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ The types of changes are: ## [Unreleased](https://github.com/ethyca/fides/compare/2.31.1...main) +### Added +- Ability to link Properties to Privacy Experiences [#4658](https://github.com/ethyca/fides/pull/4658) + ### Changed - Modify `fides user login` to not store plaintext password in `~/.fides-credentials` [#4661](https://github.com/ethyca/fides/pull/4661) - Data model changes to support Notice and Experience-level translations [#4576](https://github.com/ethyca/fides/pull/4576) diff --git a/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py b/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py index 16388dbe29..f58ab0ee9e 100644 --- a/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py +++ b/src/fides/api/alembic/migrations/versions/3815b0db5b14_drop_key_column_from_property.py @@ -1,7 +1,7 @@ """drop key column from property Revision ID: 3815b0db5b14 -Revises: 14acee6f5459 +Revises: 32497f08e227 Create Date: 2024-02-29 21:54:38.751678 """