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] 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/clients/admin-ui/cypress/fixtures/privacy-experiences/list.json b/clients/admin-ui/cypress/fixtures/privacy-experiences/list.json index 81346aab05..4b14071081 100644 --- a/clients/admin-ui/cypress/fixtures/privacy-experiences/list.json +++ b/clients/admin-ui/cypress/fixtures/privacy-experiences/list.json @@ -20,7 +20,8 @@ "version": 3.0, "created_at": "2023-05-30T23:42:10.300484+00:00", "updated_at": "2023-05-31T00:28:24.694384+00:00", - "regions": [] + "regions": [], + "properties": [] }, { "name": "notice disabled test", @@ -42,7 +43,8 @@ "version": 2.0, "created_at": "2023-05-30T23:40:11.459287+00:00", "updated_at": "2023-05-31T00:39:29.906976+00:00", - "regions": ["us_ca"] + "regions": ["us_ca"], + "properties": [{ "id": 1, "name": "Property A" }] } ], "total": 2, 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, 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/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 = ({ <> - )} + + )} 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 1052d28d6a..956f193d85 100644 --- a/clients/admin-ui/src/features/properties/property.slice.ts +++ b/clients/admin-ui/src/features/properties/property.slice.ts @@ -1,26 +1,37 @@ +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"], }), - getPropertyByKey: builder.query({ - query: (key) => ({ - url: `plus/property/${key}`, + getPropertyById: builder.query({ + query: (id) => ({ + url: `plus/property/${id}`, }), providesTags: ["Property"], }), @@ -32,12 +43,54 @@ export const propertiesApi = baseApi.injectEndpoints({ }), invalidatesTags: ["Property"], }), + updateProperty: builder.mutation< + Property, + { id: string; property: Partial } + >({ + query: ({ id, property }) => ({ + url: `plus/property/${id}`, + method: "PUT", + body: property, + }), + invalidatesTags: ["Property", "Privacy Experience Configs"], + }), }), }); -// Export hooks for using the endpoints export const { - useGetPropertiesQuery, - useGetPropertyByKeyQuery, + 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 4db7a364e2..809b521f92 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 { id: propertyId } = router.query; + const { data } = useGetPropertyByIdQuery(propertyId as string); + const [updateProperty] = useUpdatePropertyMutation(); + + const handleSubmit = async (values: FormValues) => { + const { id, ...updateValues } = values; + + const result = await updateProperty({ id: 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..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,14 @@ -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 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 +18,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/index.ts b/clients/admin-ui/src/types/api/index.ts index 4637a3398b..d8c276b3f4 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -209,6 +209,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 { MSSQLDocsSchema } from "./models/MSSQLDocsSchema"; export type { MySQLDocsSchema } from "./models/MySQLDocsSchema"; 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/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/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/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; +}; diff --git a/clients/admin-ui/src/types/api/models/Property.ts b/clients/admin-ui/src/types/api/models/Property.ts index a59e2acd37..8b8b46bbbe 100644 --- a/clients/admin-ui/src/types/api/models/Property.ts +++ b/clients/admin-ui/src/types/api/models/Property.ts @@ -2,13 +2,12 @@ /* tslint:disable */ /* eslint-disable */ -import type { PropertyType } from "./PropertyType"; +import { MinimalPrivacyExperience } from "./MinimalPrivacyExperience"; +import { PropertyType } from "./PropertyType"; -/** - * A base template for all other Fides Schemas to inherit from. - */ export type Property = { + id: string; name: string; type: PropertyType; - key?: string; + experiences: Array; }; diff --git a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts index 697f07b1c6..28f70c36c8 100644 --- a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts +++ b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts @@ -114,6 +114,7 @@ export enum ScopeRegistryEnum { PRIVACY_REQUEST_VIEW_DATA = "privacy-request:view_data", PROPERTY_CREATE = "property:create", PROPERTY_READ = "property:read", + PROPERTY_UPDATE = "property:update", RULE_CREATE_OR_UPDATE = "rule:create_or_update", RULE_DELETE = "rule:delete", RULE_READ = "rule:read", 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..f58ab0ee9e --- /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: 32497f08e227 +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 = "32497f08e227" +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/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 436ca7d523..6716b02310 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 @@ -219,6 +220,13 @@ class PrivacyExperienceConfig(PrivacyExperienceConfigBase, Base): 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""" @@ -260,6 +268,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 have ids but we don't want to use them here @@ -287,6 +296,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 @@ -305,6 +316,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) @@ -350,6 +362,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] @@ -369,6 +383,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) @@ -659,6 +674,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, @@ -674,6 +707,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 diff --git a/src/fides/api/models/property.py b/src/fides/api/models/property.py index f36c9a08de..95cd2a3ad5 100644 --- a/src/fides/api/models/property.py +++ b/src/fides/api/models/property.py @@ -1,13 +1,22 @@ from __future__ import annotations -from sqlalchemy import Column, String +import random +import string +from typing import TYPE_CHECKING, Any, Dict, List, Type +from uuid import uuid4 + +from sqlalchemy import Column, ForeignKey, String from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import RelationshipProperty, Session, 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() @@ -16,10 +25,118 @@ 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) + + experiences: RelationshipProperty[List[PrivacyExperienceConfig]] = relationship( + "PrivacyExperienceConfig", + secondary="plus_privacy_experience_config_property", + back_populates="properties", + lazy="selectin", + ) + + @classmethod + def create( + cls: Type[Property], + db: Session, + *, + data: dict[str, Any], + check_name: bool = True, + ) -> 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 + 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, + ) + + +def link_experience_configs_to_property( + db: Session, + *, + experience_configs: List[Dict[str, Any]], + 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: + experience_config_ids = [ + experience_config["id"] for experience_config in experience_configs + ] + new_experience_configs = ( + db.query(PrivacyExperienceConfig) + .filter(PrivacyExperienceConfig.id.in_(experience_config_ids)) + .all() + ) + prop.experiences = new_experience_configs + else: + prop.experiences = [] + + prop.save(db) + return prop.experiences diff --git a/src/fides/api/schemas/property.py b/src/fides/api/schemas/property.py index 5a57931c26..1b91c7d02f 100644 --- a/src/fides/api/schemas/property.py +++ b/src/fides/api/schemas/property.py @@ -1,31 +1,31 @@ -from __future__ import annotations - -import re from enum import Enum -from typing import Any, Dict, Optional - -from pydantic import root_validator +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" +class MinimalProperty(FidesSchema): + id: str + name: str + + 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 + experiences: List[MinimalPrivacyExperience] diff --git a/tests/ops/models/test_property.py b/tests/ops/models/test_property.py index 566b1d56e7..a3bf963682 100644 --- a/tests/ops/models/test_property.py +++ b/tests/ops/models/test_property.py @@ -1,25 +1,80 @@ +from typing import Any, Dict, 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: - def test_create_property(self, db): + @pytest.fixture(scope="function") + def property_a(self, db) -> Generator: + prop_a = Property.create( + db=db, + data=PropertySchema( + name="New Property", type=PropertyType.website, experiences=[] + ).dict(), + ) + yield prop_a + prop_a.delete(db=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.key == "new_property" + assert prop.id.startswith("FDS") + 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( db=db, data=PropertySchema( - name="New Property (Prod)", type=PropertyType.website + name="New Property (Prod)", type=PropertyType.website, experiences=[] ).dict(), ) assert prop.name == "New Property (Prod)" assert prop.type == PropertyType.website - assert prop.key == "new_property_prod" + assert prop.id.startswith("FDS") + assert prop.experiences == [] + + def test_update_property(self, db: Session, property_a, minimal_experience): + property_a.update( + db=db, + data={ + "name": "Property B", + "type": PropertyType.other, + "experiences": [minimal_experience], + }, + ) + + 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 len(updated_property.experiences) == 1 + + experience = updated_property.experiences[0] + assert experience.id == minimal_experience["id"] + assert experience.name == minimal_experience["name"]