diff --git a/clients/admin-ui/src/features/common/nav/nav-config.tsx b/clients/admin-ui/src/features/common/nav/nav-config.tsx index f683194b52a..5fcd091c070 100644 --- a/clients/admin-ui/src/features/common/nav/nav-config.tsx +++ b/clients/admin-ui/src/features/common/nav/nav-config.tsx @@ -406,6 +406,12 @@ if (process.env.NEXT_PUBLIC_APP_ENV === "development") { scopes: [ScopeRegistryEnum.DEVELOPER_READ], requiresPlus: true, }, + { + title: "Seed Data", + path: routes.SEED_DATA_ROUTE, + scopes: [ScopeRegistryEnum.DEVELOPER_READ], + requiresPlus: true, + }, { title: "Test monitors", path: routes.TEST_MONITORS_ROUTE, diff --git a/clients/admin-ui/src/features/common/nav/routes.ts b/clients/admin-ui/src/features/common/nav/routes.ts index 4fa465f5d6e..a7bedce7067 100644 --- a/clients/admin-ui/src/features/common/nav/routes.ts +++ b/clients/admin-ui/src/features/common/nav/routes.ts @@ -105,6 +105,7 @@ export const ERRORS_POC_ROUTE = "/poc/error"; export const TABLE_MIGRATION_POC_ROUTE = "/poc/table-migration"; export const FIDES_JS_DOCS = "/fides-js-docs"; export const PROMPT_EXPLORER_ROUTE = "/poc/prompt-explorer"; +export const SEED_DATA_ROUTE = "/poc/seed-data"; export const TEST_MONITORS_ROUTE = "/poc/test-monitors"; // RBAC routes diff --git a/clients/admin-ui/src/features/dashboard/constants.ts b/clients/admin-ui/src/features/dashboard/constants.ts index 12f8f91b3e3..1bbcf56d927 100644 --- a/clients/admin-ui/src/features/dashboard/constants.ts +++ b/clients/admin-ui/src/features/dashboard/constants.ts @@ -74,8 +74,11 @@ export const ACTION_CTA: Record< }, [ActionType.DSR_ACTION]: { label: "View request", - route: (d) => - d.request_id ? `/privacy-requests/${d.request_id}` : "/privacy-requests", + route: (d) => { + if (d.request_id) return `/privacy-requests/${d.request_id}`; + if (d.is_overdue) return "/privacy-requests?is_overdue=true"; + return "/privacy-requests"; + }, }, [ActionType.SYSTEM_REVIEW]: { label: "Review system", diff --git a/clients/admin-ui/src/features/privacy-requests/dashboard/hooks/usePrivacyRequestsFilters.ts b/clients/admin-ui/src/features/privacy-requests/dashboard/hooks/usePrivacyRequestsFilters.ts index b517311871c..d30c80e8373 100644 --- a/clients/admin-ui/src/features/privacy-requests/dashboard/hooks/usePrivacyRequestsFilters.ts +++ b/clients/admin-ui/src/features/privacy-requests/dashboard/hooks/usePrivacyRequestsFilters.ts @@ -1,5 +1,6 @@ import { parseAsArrayOf, + parseAsBoolean, parseAsString, parseAsStringEnum, useQueryStates, @@ -19,6 +20,7 @@ export interface FilterQueryParams { to: string | null; status: PrivacyRequestStatus[] | null; action_type: ActionType[] | null; + is_overdue: boolean | null; location: string | null; custom_privacy_request_fields: Record | null; sort_field: string | null; @@ -52,6 +54,7 @@ const usePrivacyRequestsFilters = ({ to: parseAsString, status: parseAsArrayOf(parseAsStringEnum(allowedStatusFilterOptions)), action_type: parseAsArrayOf(parseAsStringEnum(Object.values(ActionType))), + is_overdue: parseAsBoolean, location: parseAsString, custom_privacy_request_fields: parseAsCustomFields, }, @@ -77,6 +80,7 @@ const usePrivacyRequestsFilters = ({ to: filters.to, status: filters.status, action_type: filters.action_type, + is_overdue: filters.is_overdue, location: filters.location, custom_privacy_request_fields: filterNullCustomFields( filters.custom_privacy_request_fields, @@ -90,6 +94,7 @@ const usePrivacyRequestsFilters = ({ filters.to, filters.status, filters.action_type, + filters.is_overdue, filters.location, filters.custom_privacy_request_fields, sortState.sort_field, diff --git a/clients/admin-ui/src/features/privacy-requests/dashboard/list-item/components/DaysLeft.tsx b/clients/admin-ui/src/features/privacy-requests/dashboard/list-item/components/DaysLeft.tsx index 05f503114bd..94ba0f4965f 100644 --- a/clients/admin-ui/src/features/privacy-requests/dashboard/list-item/components/DaysLeft.tsx +++ b/clients/admin-ui/src/features/privacy-requests/dashboard/list-item/components/DaysLeft.tsx @@ -29,14 +29,20 @@ export const DaysLeft = ({ !DAY_IRRELEVANT_STATUSES.includes(status); if (showBadge) { + const isOverdue = daysLeft < 0; const percentage = (100 * daysLeft) / timeframe; const color = - percentage < 25 ? CUSTOM_TAG_COLOR.ERROR : CUSTOM_TAG_COLOR.DEFAULT; + isOverdue || percentage < 25 + ? CUSTOM_TAG_COLOR.ERROR + : CUSTOM_TAG_COLOR.DEFAULT; + const label = isOverdue + ? `${Math.abs(daysLeft)} days overdue` + : `${daysLeft} days left`; return (
- <>{daysLeft} days left + <>{label}
diff --git a/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts b/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts index 641593dab71..e4bd52ad907 100644 --- a/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts +++ b/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts @@ -146,6 +146,7 @@ export const selectPrivacyRequestFilters = createSelector( from: subjectRequests.from, id: subjectRequests.id, fuzzy_search_str: subjectRequests.fuzzy_search_str, + is_overdue: subjectRequests.is_overdue, page: subjectRequests.page, size: subjectRequests.size, sort_direction: subjectRequests.sort_direction, @@ -175,6 +176,7 @@ type SubjectRequestsState = { from: string; id: string; fuzzy_search_str?: string; + is_overdue?: boolean; page: number; size: number; sort_direction?: string; @@ -251,6 +253,11 @@ export const subjectRequestsSlice = createSlice({ page: initialState.page, action_type: action.payload, }), + setRequestOverdue: (state, action: PayloadAction) => ({ + ...state, + page: initialState.page, + is_overdue: action.payload, + }), setRequestTo: (state, action: PayloadAction) => ({ ...state, page: initialState.page, @@ -289,6 +296,7 @@ export const { setRequestId, setRequestStatus, setRequestActionType, + setRequestOverdue, setRequestTo, setRetryRequests, setSortDirection, diff --git a/clients/admin-ui/src/features/privacy-requests/types.ts b/clients/admin-ui/src/features/privacy-requests/types.ts index 210cfb305fe..bf360e27746 100644 --- a/clients/admin-ui/src/features/privacy-requests/types.ts +++ b/clients/admin-ui/src/features/privacy-requests/types.ts @@ -130,6 +130,7 @@ export interface PrivacyRequestParams { id: string; from: string; to: string; + is_overdue?: boolean; page: number; size: number; verbose?: boolean; diff --git a/clients/admin-ui/src/features/seed-data/SeedDataPanel.tsx b/clients/admin-ui/src/features/seed-data/SeedDataPanel.tsx new file mode 100644 index 00000000000..531e2c57e61 --- /dev/null +++ b/clients/admin-ui/src/features/seed-data/SeedDataPanel.tsx @@ -0,0 +1,209 @@ +import { + Alert, + Button, + Card, + Checkbox, + Flex, + Progress, + Space, + Tag, + Typography, +} from "fidesui"; +import { useCallback, useEffect, useState } from "react"; + +import { + SeedStepStatus, + SeedTasksConfig, + useGetSeedStatusQuery, + useTriggerSeedMutation, +} from "~/features/seed-data/seed-data.slice"; + +const { Paragraph, Text } = Typography; + +interface SeedScenario { + key: keyof SeedTasksConfig; + label: string; + description: string; +} + +const SEED_SCENARIOS: SeedScenario[] = [ + { + key: "pbac", + label: "PBAC (Access Control)", + description: + "Seed data purposes, data consumers, datasets, a mock query log integration, and 60 days of access control history.", + }, + { + key: "dashboard", + label: "Dashboard (Landing Page)", + description: + "Populate the landing page dashboard with privacy requests, system coverage metrics, audit activity, staged resources, and 30 days of trend history.", + }, +]; + +const STATUS_COLORS = { + pending: "default", + in_progress: "processing", + complete: "success", + skipped: "warning", + error: "error", +} as const; + +const StepStatusTag = ({ status }: { status: SeedStepStatus }) => { + const color = + STATUS_COLORS[status.status as keyof typeof STATUS_COLORS] ?? "default"; + const style = color === "processing" ? { color: "white" } : undefined; + return ( + + {status.status.replace("_", " ")} + + ); +}; + +const computeProgress = (steps: Record): number => { + const entries = Object.values(steps); + if (entries.length === 0) { + return 0; + } + const done = entries.filter( + (step) => + step.status === "complete" || + step.status === "skipped" || + step.status === "error", + ).length; + return Math.round((done / entries.length) * 100); +}; + +const SeedDataPanel = () => { + const [selectedTasks, setSelectedTasks] = useState< + Set + >(new Set()); + const [executionId, setExecutionId] = useState(null); + const [triggerSeed, { isLoading: isTriggering, isError: isTriggerError }] = + useTriggerSeedMutation(); + + // Poll for status when an execution is in progress + const { data: statusData } = useGetSeedStatusQuery(executionId!, { + skip: !executionId, + // Seed tasks complete within seconds; 2s polling is intentional for this dev-only tool. + pollingInterval: 2000, + }); + + // Stop polling when execution completes + useEffect(() => { + if ( + statusData && + (statusData.status === "complete" || statusData.status === "error") + ) { + setExecutionId(null); + } + }, [statusData]); + + const handleToggle = useCallback((key: keyof SeedTasksConfig) => { + setSelectedTasks((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + const handleSeed = useCallback(async () => { + const tasks: SeedTasksConfig = {}; + selectedTasks.forEach((key) => { + tasks[key] = true; + }); + + const result = await triggerSeed({ tasks }) + .unwrap() + .catch(() => null); + if (result) { + setExecutionId(result.execution_id); + } + }, [selectedTasks, triggerSeed]); + + const isRunning = !!executionId || isTriggering; + const showStatus = statusData && statusData.status !== "pending"; + + return ( + + + Select which data scenarios to seed into this environment. Seeding is + idempotent — it is safe to run multiple times. + + + + {SEED_SCENARIOS.map((scenario) => ( + handleToggle(scenario.key)} + disabled={isRunning} + > + {scenario.label} +
+ {scenario.description} +
+ ))} +
+ + + + + + {isTriggerError ? ( + + ) : null} + + {showStatus ? ( + + + + {Object.entries(statusData.steps).map(([stepName, stepStatus]) => ( + + {stepName.replace(/_/g, " ")} + + + ))} + + {statusData.error ? ( + + ) : null} + {statusData.status === "complete" ? ( + + ) : null} + + ) : null} +
+ ); +}; + +export default SeedDataPanel; diff --git a/clients/admin-ui/src/features/seed-data/seed-data.slice.ts b/clients/admin-ui/src/features/seed-data/seed-data.slice.ts new file mode 100644 index 00000000000..76a5c9206ed --- /dev/null +++ b/clients/admin-ui/src/features/seed-data/seed-data.slice.ts @@ -0,0 +1,110 @@ +import { baseApi } from "~/features/common/api.slice"; + +export interface SeedTasksConfig { + sample_resources?: boolean; + messaging_mailgun?: boolean; + storage_s3?: boolean; + linked_connections?: boolean; + consent_notices?: boolean; + consent_experiences?: boolean; + compass_vendors?: boolean; + email_templates?: boolean; + discovery_monitors?: boolean; + pbac?: boolean; + dashboard?: boolean; +} + +export interface SeedRequest { + tasks: SeedTasksConfig; +} + +export interface SeedResponse { + execution_id: string; + message: string; +} + +export interface SeedStepStatus { + status: "pending" | "in_progress" | "complete" | "skipped" | "error"; + message?: string; + started_at?: string; + completed_at?: string; +} + +export interface SeedStatusResponse { + id: string; + status: "pending" | "in_progress" | "complete" | "error"; + started_at: string; + completed_at?: string; + steps: Record; + error?: string; +} + +/** + * Cache tags to invalidate when seeding completes. + * Maps each seed task to the tags it affects. + */ +const SEED_TASK_INVALIDATION_TAGS: Record = { + pbac: [ + "DataPurpose", + "DataConsumer", + "QueryLogConfig", + "Datastore Connection", + "Dataset", + "Datasets", + ], + sample_resources: ["System", "Dataset", "Datasets", "Policies"], + linked_connections: ["Datastore Connection"], + consent_notices: ["Privacy Notices"], + consent_experiences: ["Privacy Experience Configs"], + compass_vendors: ["System", "Dictionary", "System Vendors"], + email_templates: ["Messaging Templates"], + discovery_monitors: ["Discovery Monitor Configs", "Datastore Connection"], + messaging_mailgun: ["Messaging Config"], + storage_s3: ["Configuration Settings"], + dashboard: [ + "System", + "Privacy Requests", + "Fides Dashboard", + ], +}; + +const seedDataApi = baseApi.injectEndpoints({ + endpoints: (build) => ({ + triggerSeed: build.mutation({ + query: (body) => ({ + url: "plus/seed", + method: "POST", + body, + }), + // Invalidate tags for all enabled tasks so cached data refreshes + // after the user navigates to the seeded pages. + invalidatesTags: (_result, _error, arg) => { + const tags: string[] = []; + const { tasks } = arg; + (Object.keys(tasks) as (keyof SeedTasksConfig)[]).forEach((key) => { + if (tasks[key] && SEED_TASK_INVALIDATION_TAGS[key]) { + tags.push(...SEED_TASK_INVALIDATION_TAGS[key]); + } + }); + // Deduplicate and cast to satisfy RTK Query's TagDescription type + return [...new Set(tags)] as typeof tags; + }, + }), + getSeedStatus: build.query({ + query: (executionId) => ({ + url: `plus/seed/status/${executionId}`, + }), + }), + getLatestSeedStatus: build.query({ + query: () => ({ + url: "plus/seed/status", + }), + }), + }), +}); + +export const { + useTriggerSeedMutation, + useGetSeedStatusQuery, + useGetLatestSeedStatusQuery, +} = seedDataApi; diff --git a/clients/admin-ui/src/home/DSRStatusCard.module.scss b/clients/admin-ui/src/home/DSRStatusCard.module.scss index cf0cec32451..7ff51ed2421 100644 --- a/clients/admin-ui/src/home/DSRStatusCard.module.scss +++ b/clients/admin-ui/src/home/DSRStatusCard.module.scss @@ -1,9 +1,18 @@ +.spinWrapper { + height: 100%; + + :global(.ant-spin-container) { + height: 100%; + } +} + .cardContainer { height: 100%; :global(.ant-card-body) { display: flex; flex-direction: column; + flex: 1; } :global(.ant-statistic) { diff --git a/clients/admin-ui/src/home/DSRStatusCard.tsx b/clients/admin-ui/src/home/DSRStatusCard.tsx index 85bda6f49eb..7e64dc7e4e1 100644 --- a/clients/admin-ui/src/home/DSRStatusCard.tsx +++ b/clients/admin-ui/src/home/DSRStatusCard.tsx @@ -38,15 +38,21 @@ export const DSRStatusCard = () => { const handleTypeClick = useCallback( (type: string) => { - router.push(`${PRIVACY_REQUESTS_ROUTE}?action_type=${type}`); + router.push( + `${PRIVACY_REQUESTS_ROUTE}?action_type=${type.toLowerCase()}`, + ); }, [router], ); + const handleOverdueClick = useCallback(() => { + router.push(`${PRIVACY_REQUESTS_ROUTE}?is_overdue=true`); + }, [router]); + return ( - + { {SUB_STATS.map(({ key, title }) => ( { {(data?.overdue_count ?? 0) > 0 && ( - @@ -101,13 +108,13 @@ export const DSRStatusCard = () => { - + )} - SLA Health + SLA health {sla && ( diff --git a/clients/admin-ui/src/home/HomeDashboard.tsx b/clients/admin-ui/src/home/HomeDashboard.tsx index ec14a6a43d9..dfa2c4da5af 100644 --- a/clients/admin-ui/src/home/HomeDashboard.tsx +++ b/clients/admin-ui/src/home/HomeDashboard.tsx @@ -70,14 +70,14 @@ export const HomeDashboard = () => { ))} - - +
+
- - +
+
- - +
+
); diff --git a/clients/admin-ui/src/home/PostureCard.tsx b/clients/admin-ui/src/home/PostureCard.tsx index 387e8c93810..28594a4db63 100644 --- a/clients/admin-ui/src/home/PostureCard.tsx +++ b/clients/admin-ui/src/home/PostureCard.tsx @@ -88,7 +88,7 @@ export const PostureCard = () => { return ( diff --git a/clients/admin-ui/src/home/PriorityActionsCard.tsx b/clients/admin-ui/src/home/PriorityActionsCard.tsx index 2e3b1ef3001..4c85bd3507e 100644 --- a/clients/admin-ui/src/home/PriorityActionsCard.tsx +++ b/clients/admin-ui/src/home/PriorityActionsCard.tsx @@ -12,6 +12,9 @@ import { import NextLink from "next/link"; import { type ReactNode, useMemo, useState } from "react"; +import RequestStatusBadge, { + statusPropMap, +} from "~/features/common/RequestStatusBadge"; import { ACTION_CTA, DIMENSION_LABELS, @@ -134,7 +137,27 @@ export const PriorityActionsCard = () => { /> } title={action.title} - description={action.message} + description={ + action.action_data?.status && + typeof action.action_data.status === "string" && + action.action_data.status in statusPropMap ? ( + + + + + {action.message} + + + + ) : ( + action.message + ) + } /> ); diff --git a/clients/admin-ui/src/home/SystemCoverageCard.tsx b/clients/admin-ui/src/home/SystemCoverageCard.tsx index f8da4f3c33d..ca79896adcc 100644 --- a/clients/admin-ui/src/home/SystemCoverageCard.tsx +++ b/clients/admin-ui/src/home/SystemCoverageCard.tsx @@ -18,7 +18,7 @@ export const SystemCoverageCard = () => { return ( - +
@@ -59,7 +59,7 @@ export const SystemCoverageCard = () => { diff --git a/clients/admin-ui/src/home/TrendCard.tsx b/clients/admin-ui/src/home/TrendCard.tsx index 4b95755ffff..13511500ea3 100644 --- a/clients/admin-ui/src/home/TrendCard.tsx +++ b/clients/admin-ui/src/home/TrendCard.tsx @@ -9,11 +9,11 @@ import cardStyles from "./dashboard-card.module.scss"; type StatType = "number" | "percent"; const TREND_METRIC_CONFIG = { - gps_score: { label: "GPS Score", statType: "number" }, - dsr_volume: { label: "DSR Volume", statType: "number" }, - system_coverage: { label: "System Coverage", statType: "percent" }, + gps_score: { label: "Governance posture", statType: "number" }, + dsr_volume: { label: "DSR volume", statType: "number" }, + system_coverage: { label: "System coverage", statType: "percent" }, classification_health: { - label: "Classification Health", + label: "Classification health", statType: "percent", }, } satisfies Record; diff --git a/clients/admin-ui/src/pages/poc/seed-data.tsx b/clients/admin-ui/src/pages/poc/seed-data.tsx new file mode 100644 index 00000000000..a2ee4cbc2bc --- /dev/null +++ b/clients/admin-ui/src/pages/poc/seed-data.tsx @@ -0,0 +1,22 @@ +import { Layout, Typography } from "fidesui"; +import type { NextPage } from "next"; + +import PageHeader from "~/features/common/PageHeader"; +import SeedDataPanel from "~/features/seed-data/SeedDataPanel"; + +const { Content } = Layout; +const { Paragraph } = Typography; + +const SeedDataPage: NextPage = () => { + return ( + + + + Seed demo data into this environment for testing and development. + + + + ); +}; + +export default SeedDataPage; diff --git a/clients/fidesui/src/components/charts/StackedBarChart.tsx b/clients/fidesui/src/components/charts/StackedBarChart.tsx index d0ee988ea42..3db42506657 100644 --- a/clients/fidesui/src/components/charts/StackedBarChart.tsx +++ b/clients/fidesui/src/components/charts/StackedBarChart.tsx @@ -203,17 +203,36 @@ export const StackedBarChart = ({ .filter((entry): entry is ChartEntry => entry !== null); }, [data, segments]); - if (chartData.length === 0) { + // Sort alphabetically so ordering is deterministic (e.g. Access before Erasure) + const sortedData = useMemo( + () => [...chartData].sort((a, b) => a.category.localeCompare(b.category)), + [chartData], + ); + + // Compute YAxis width dynamically from the longest label + const yAxisWidth = useMemo(() => { + if (sortedData.length === 0) return 80; + const longestLabel = sortedData.reduce( + (max, entry) => + entry.category.length > max.length ? entry.category : max, + "", + ); + const charWidth = token.fontSizeSM * 0.65; + const iconSpace = onCategoryClick ? ICON_SIZE + ICON_GAP + 8 : 8; + return Math.ceil(longestLabel.length * charWidth + iconSpace); + }, [sortedData, token.fontSizeSM, onCategoryClick]); + + if (sortedData.length === 0) { return null; } const chartHeight = - chartData.length * barHeight + (chartData.length - 1) * rowGap + rowGap; + sortedData.length * barHeight + (sortedData.length - 1) * rowGap + rowGap; return ( } tickLine={false} diff --git a/noxfiles/ci_nox.py b/noxfiles/ci_nox.py index 513ac67bb80..dea8f8f7d32 100644 --- a/noxfiles/ci_nox.py +++ b/noxfiles/ci_nox.py @@ -560,6 +560,7 @@ def pytest_redis_cluster_docker(session: nox.Session) -> None: "misc-integration-external", ], "tests/task/": ["misc-unit", "misc-integration", "misc-integration-external"], + "tests/unit/": ["misc-unit"], "tests/util/": ["misc-unit", "misc-integration", "misc-integration-external"], "tests/qa/": ["misc-unit", "misc-integration", "misc-integration-external"], "tests/integration/": ["ops-integration"], # Workflow integration tests diff --git a/noxfiles/setup_tests_nox.py b/noxfiles/setup_tests_nox.py index a9c0b46e212..2002d9665cc 100644 --- a/noxfiles/setup_tests_nox.py +++ b/noxfiles/setup_tests_nox.py @@ -462,6 +462,7 @@ def pytest_misc_unit(session: Session, pytest_config: PytestConfig) -> None: "tests/service/", "tests/system_integration_link/", "tests/task/", + "tests/unit/", "tests/util/", "-m", "not integration and not integration_external and not integration_saas and not integration_snowflake and not integration_bigquery and not integration_postgres", diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_03_24_1700_29113e44faec_add_dashboard_snapshot.py b/src/fides/api/alembic/migrations/versions/xx_2026_03_24_1700_29113e44faec_add_dashboard_snapshot.py new file mode 100644 index 00000000000..5a50d61e0df --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_03_24_1700_29113e44faec_add_dashboard_snapshot.py @@ -0,0 +1,67 @@ +"""add dashboard_snapshot table + +Revision ID: 29113e44faec +Revises: 94273d7e8319 +Create Date: 2026-03-24 17:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "29113e44faec" +down_revision = "190e4603ad38" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "dashboard_snapshot", + sa.Column("id", sa.String(length=255), nullable=False), + 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("snapshot_date", sa.Date(), nullable=False), + sa.Column("metric_key", sa.String(), nullable=False), + sa.Column("value", sa.Float(), nullable=False), + sa.Column( + "metadata", + postgresql.JSONB(astext_type=sa.Text()), + server_default="{}", + nullable=True, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "snapshot_date", + "metric_key", + name="uq_dashboard_snapshot_date_metric", + ), + ) + op.create_index( + "ix_dashboard_snapshot_snapshot_date", + "dashboard_snapshot", + ["snapshot_date"], + ) + op.create_index( + "ix_dashboard_snapshot_metric_key", + "dashboard_snapshot", + ["metric_key"], + ) + + +def downgrade(): + op.drop_index("ix_dashboard_snapshot_metric_key", table_name="dashboard_snapshot") + op.drop_index("ix_dashboard_snapshot_snapshot_date", table_name="dashboard_snapshot") + op.drop_table("dashboard_snapshot") diff --git a/src/fides/api/db/base.py b/src/fides/api/db/base.py index 8b6add82bb6..9c47f16484e 100644 --- a/src/fides/api/db/base.py +++ b/src/fides/api/db/base.py @@ -16,6 +16,7 @@ from fides.api.models.custom_asset import CustomAsset from fides.api.models.custom_connector_template import CustomConnectorTemplate from fides.api.models.custom_report import CustomReport +from fides.api.models.dashboard_snapshot import DashboardSnapshot from fides.api.models.datasetconfig import DatasetConfig from fides.api.models.db_cache import DBCache from fides.api.models.detection_discovery.core import MonitorConfig, StagedResource diff --git a/src/fides/api/models/dashboard_snapshot.py b/src/fides/api/models/dashboard_snapshot.py new file mode 100644 index 00000000000..64a79f07171 --- /dev/null +++ b/src/fides/api/models/dashboard_snapshot.py @@ -0,0 +1,29 @@ +"""Daily metric snapshots for dashboard trend sparklines.""" + +from sqlalchemy import Column, Date, Float, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB + +from fides.api.db.base_class import Base + + +class DashboardSnapshot(Base): + """Stores daily metric values for dashboard trend visualization. + + Each row is one metric on one day. The seed task populates historical + data; a future Celery beat task will append daily snapshots. + """ + + __tablename__ = "dashboard_snapshot" # type: ignore[assignment] + + snapshot_date = Column(Date, nullable=False, index=True) + metric_key = Column(String, nullable=False, index=True) + value = Column(Float, nullable=False) + metadata_ = Column("metadata", JSONB, nullable=True, server_default="{}") + + __table_args__ = ( + UniqueConstraint( + "snapshot_date", + "metric_key", + name="uq_dashboard_snapshot_date_metric", + ), + ) diff --git a/src/fides/api/schemas/privacy_request.py b/src/fides/api/schemas/privacy_request.py index f938a34ad8c..7a12ab60bdf 100644 --- a/src/fides/api/schemas/privacy_request.py +++ b/src/fides/api/schemas/privacy_request.py @@ -325,6 +325,19 @@ class PrivacyRequestStatus(StrEnum): duplicate = "duplicate" # Request identified as duplicate of another request +ACTIVE_REQUEST_STATUSES = frozenset( + { + PrivacyRequestStatus.in_processing, + PrivacyRequestStatus.pending, + PrivacyRequestStatus.approved, + PrivacyRequestStatus.paused, + PrivacyRequestStatus.requires_input, + PrivacyRequestStatus.requires_manual_finalization, + PrivacyRequestStatus.pending_external, + } +) + + class IdentityValue(BaseModel): """Represents an identity value with a label in API responses. @@ -576,6 +589,7 @@ class PrivacyRequestFilter(FidesSchema): include_identities: Optional[bool] = False include_custom_privacy_request_fields: Optional[bool] = False include_deleted_requests: Optional[bool] = False + is_overdue: Optional[bool] = None download_csv: Optional[bool] = False sort_field: str = "created_at" sort_direction: ColumnSort = ColumnSort.DESC diff --git a/src/fides/service/privacy_request/privacy_request_query_utils.py b/src/fides/service/privacy_request/privacy_request_query_utils.py index 7689daf564d..ab3f2af002c 100644 --- a/src/fides/service/privacy_request/privacy_request_query_utils.py +++ b/src/fides/service/privacy_request/privacy_request_query_utils.py @@ -1,4 +1,5 @@ # pylint: disable=too-many-branches, too-many-statements +from datetime import datetime, timezone from typing import Optional from sqlalchemy.orm import Session @@ -14,6 +15,7 @@ ) from fides.api.schemas.policy import ActionType from fides.api.schemas.privacy_request import ( + ACTIVE_REQUEST_STATUSES, MAX_BULK_FILTER_RESULTS, PrivacyRequestBulkSelection, PrivacyRequestFilter, @@ -223,6 +225,20 @@ def filter_privacy_request_queryset( ) query = query.filter(PrivacyRequest.policy_id.in_(policy_ids_for_action_type)) + if filters.is_overdue is True: + query = query.filter( + PrivacyRequest.due_date.isnot(None), + PrivacyRequest.due_date < datetime.now(timezone.utc), + PrivacyRequest.status.in_(list(ACTIVE_REQUEST_STATUSES)), + ) + elif filters.is_overdue is False: + query = query.filter( + or_( + PrivacyRequest.due_date.is_(None), + PrivacyRequest.due_date >= datetime.now(timezone.utc), + ) + ) + if not include_consent_webhook_requests: query = query.filter( or_( diff --git a/tests/unit/test_active_request_statuses.py b/tests/unit/test_active_request_statuses.py new file mode 100644 index 00000000000..d0a76521215 --- /dev/null +++ b/tests/unit/test_active_request_statuses.py @@ -0,0 +1,42 @@ +"""Tests for ACTIVE_REQUEST_STATUSES and is_overdue filter.""" + +from fides.api.schemas.privacy_request import ( + ACTIVE_REQUEST_STATUSES, + PrivacyRequestFilter, + PrivacyRequestStatus, +) + + +class TestActiveRequestStatuses: + def test_contains_expected_active_statuses(self): + assert PrivacyRequestStatus.in_processing in ACTIVE_REQUEST_STATUSES + assert PrivacyRequestStatus.pending in ACTIVE_REQUEST_STATUSES + assert PrivacyRequestStatus.approved in ACTIVE_REQUEST_STATUSES + assert PrivacyRequestStatus.paused in ACTIVE_REQUEST_STATUSES + assert PrivacyRequestStatus.requires_input in ACTIVE_REQUEST_STATUSES + assert ( + PrivacyRequestStatus.requires_manual_finalization in ACTIVE_REQUEST_STATUSES + ) + assert PrivacyRequestStatus.pending_external in ACTIVE_REQUEST_STATUSES + + def test_excludes_terminal_statuses(self): + assert PrivacyRequestStatus.complete not in ACTIVE_REQUEST_STATUSES + assert PrivacyRequestStatus.canceled not in ACTIVE_REQUEST_STATUSES + assert PrivacyRequestStatus.error not in ACTIVE_REQUEST_STATUSES + assert PrivacyRequestStatus.denied not in ACTIVE_REQUEST_STATUSES + assert PrivacyRequestStatus.duplicate not in ACTIVE_REQUEST_STATUSES + assert PrivacyRequestStatus.identity_unverified not in ACTIVE_REQUEST_STATUSES + + +class TestPrivacyRequestFilterOverdue: + def test_accepts_true(self): + f = PrivacyRequestFilter(is_overdue=True) + assert f.is_overdue is True + + def test_accepts_false(self): + f = PrivacyRequestFilter(is_overdue=False) + assert f.is_overdue is False + + def test_defaults_to_none(self): + f = PrivacyRequestFilter() + assert f.is_overdue is None