ethyca-fides 2.58.2b3__py2.py3-none-any.whl → 2.58.2b5__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. {ethyca_fides-2.58.2b3.dist-info → ethyca_fides-2.58.2b5.dist-info}/METADATA +20 -11
  2. {ethyca_fides-2.58.2b3.dist-info → ethyca_fides-2.58.2b5.dist-info}/RECORD +108 -107
  3. {ethyca_fides-2.58.2b3.dist-info → ethyca_fides-2.58.2b5.dist-info}/WHEEL +1 -1
  4. {ethyca_fides-2.58.2b3.dist-info → ethyca_fides-2.58.2b5.dist-info}/entry_points.txt +0 -1
  5. fides/_version.py +3 -3
  6. fides/api/alembic/migrations/versions/9288f729cac4_add_tcf_configuration_fk_to_experience_.py +62 -0
  7. fides/api/models/privacy_experience.py +26 -0
  8. fides/api/models/tcf_publisher_restrictions.py +209 -48
  9. fides/api/util/collection_util.py +48 -9
  10. fides/cli/commands/pull.py +77 -13
  11. fides/core/api.py +2 -1
  12. fides/core/pull.py +38 -7
  13. fides/ui-build/static/admin/404.html +1 -1
  14. fides/ui-build/static/admin/_next/static/{S7gURhIaHGAv7MFBTEOOS → _o6WH0hDzNEhnUJyvLex7}/_buildManifest.js +1 -1
  15. fides/ui-build/static/admin/_next/static/chunks/1376-87058e04584cff20.js +1 -0
  16. fides/ui-build/static/admin/_next/static/chunks/4121-4d5273d7a354994d.js +1 -0
  17. fides/ui-build/static/admin/_next/static/chunks/{4450-6a8aa0d7358ac26f.js → 4450-9c3086ccb55c66aa.js} +1 -1
  18. fides/ui-build/static/admin/_next/static/chunks/6315-24a0483ee1cab6cc.js +1 -0
  19. fides/ui-build/static/admin/_next/static/chunks/{9046-8a5fdd335a76d224.js → 9046-a69fa8f99c414570.js} +1 -1
  20. fides/ui-build/static/admin/_next/static/chunks/pages/{_app-fbe3db87623c87a0.js → _app-0c1548ca3b158123.js} +1 -1
  21. fides/ui-build/static/admin/_next/static/chunks/pages/data-discovery/action-center-17d1525551d8904f.js +1 -0
  22. fides/ui-build/static/admin/_next/static/chunks/pages/data-discovery/activity-7d2cb947eee11262.js +1 -0
  23. fides/ui-build/static/admin/_next/static/chunks/pages/messaging-26407674949bcbc4.js +1 -0
  24. fides/ui-build/static/admin/_next/static/chunks/pages/privacy-requests/configure/messaging-28d4bdf060ec8cb2.js +1 -0
  25. fides/ui-build/static/admin/_next/static/chunks/pages/privacy-requests/configure/storage-208e49ef43361d6f.js +1 -0
  26. fides/ui-build/static/admin/_next/static/chunks/pages/settings/consent-ff1985f72d50ef47.js +1 -0
  27. fides/ui-build/static/admin/_next/static/chunks/pages/settings/domains-5a0b10ec955097d4.js +1 -0
  28. fides/ui-build/static/admin/_next/static/chunks/pages/user-management/{new-f8bca2e322ddf252.js → new-082c3156175f9267.js} +1 -1
  29. fides/ui-build/static/admin/_next/static/chunks/pages/user-management/profile/[id]-af83245e9373a064.js +1 -0
  30. fides/ui-build/static/admin/add-systems/manual.html +1 -1
  31. fides/ui-build/static/admin/add-systems/multiple.html +1 -1
  32. fides/ui-build/static/admin/add-systems.html +1 -1
  33. fides/ui-build/static/admin/ant-poc.html +1 -1
  34. fides/ui-build/static/admin/consent/configure/add-vendors.html +1 -1
  35. fides/ui-build/static/admin/consent/configure.html +1 -1
  36. fides/ui-build/static/admin/consent/privacy-experience/[id].html +1 -1
  37. fides/ui-build/static/admin/consent/privacy-experience/new.html +1 -1
  38. fides/ui-build/static/admin/consent/privacy-experience.html +1 -1
  39. fides/ui-build/static/admin/consent/privacy-notices/[id].html +1 -1
  40. fides/ui-build/static/admin/consent/privacy-notices/new.html +1 -1
  41. fides/ui-build/static/admin/consent/privacy-notices.html +1 -1
  42. fides/ui-build/static/admin/consent/properties.html +1 -1
  43. fides/ui-build/static/admin/consent/reporting.html +1 -1
  44. fides/ui-build/static/admin/consent.html +1 -1
  45. fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn]/[resourceUrn].html +1 -1
  46. fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn].html +1 -1
  47. fides/ui-build/static/admin/data-catalog/[systemId]/projects.html +1 -1
  48. fides/ui-build/static/admin/data-catalog/[systemId]/resources/[resourceUrn].html +1 -1
  49. fides/ui-build/static/admin/data-catalog/[systemId]/resources.html +1 -1
  50. fides/ui-build/static/admin/data-catalog.html +1 -1
  51. fides/ui-build/static/admin/data-discovery/action-center/[monitorId]/[systemId].html +1 -1
  52. fides/ui-build/static/admin/data-discovery/action-center/[monitorId].html +1 -1
  53. fides/ui-build/static/admin/data-discovery/action-center.html +1 -1
  54. fides/ui-build/static/admin/data-discovery/activity.html +1 -1
  55. fides/ui-build/static/admin/data-discovery/detection/[resourceUrn].html +1 -1
  56. fides/ui-build/static/admin/data-discovery/detection.html +1 -1
  57. fides/ui-build/static/admin/data-discovery/discovery/[resourceUrn].html +1 -1
  58. fides/ui-build/static/admin/data-discovery/discovery.html +1 -1
  59. fides/ui-build/static/admin/datamap.html +1 -1
  60. fides/ui-build/static/admin/dataset/[datasetId]/[collectionName]/[...subfieldNames].html +1 -1
  61. fides/ui-build/static/admin/dataset/[datasetId]/[collectionName].html +1 -1
  62. fides/ui-build/static/admin/dataset/[datasetId].html +1 -1
  63. fides/ui-build/static/admin/dataset/new.html +1 -1
  64. fides/ui-build/static/admin/dataset.html +1 -1
  65. fides/ui-build/static/admin/datastore-connection/[id].html +1 -1
  66. fides/ui-build/static/admin/datastore-connection/new.html +1 -1
  67. fides/ui-build/static/admin/datastore-connection.html +1 -1
  68. fides/ui-build/static/admin/index.html +1 -1
  69. fides/ui-build/static/admin/integrations/[id].html +1 -1
  70. fides/ui-build/static/admin/integrations.html +1 -1
  71. fides/ui-build/static/admin/lib/fides-tcf.js +1 -1
  72. fides/ui-build/static/admin/login/[provider].html +1 -1
  73. fides/ui-build/static/admin/login.html +1 -1
  74. fides/ui-build/static/admin/messaging/[id].html +1 -1
  75. fides/ui-build/static/admin/messaging/add-template.html +1 -1
  76. fides/ui-build/static/admin/messaging.html +1 -1
  77. fides/ui-build/static/admin/privacy-requests/[id].html +1 -1
  78. fides/ui-build/static/admin/privacy-requests/configure/messaging.html +1 -1
  79. fides/ui-build/static/admin/privacy-requests/configure/storage.html +1 -1
  80. fides/ui-build/static/admin/privacy-requests/configure.html +1 -1
  81. fides/ui-build/static/admin/privacy-requests.html +1 -1
  82. fides/ui-build/static/admin/properties/[id].html +1 -1
  83. fides/ui-build/static/admin/properties/add-property.html +1 -1
  84. fides/ui-build/static/admin/properties.html +1 -1
  85. fides/ui-build/static/admin/reporting/datamap.html +1 -1
  86. fides/ui-build/static/admin/settings/about.html +1 -1
  87. fides/ui-build/static/admin/settings/consent.html +1 -1
  88. fides/ui-build/static/admin/settings/custom-fields.html +1 -1
  89. fides/ui-build/static/admin/settings/domain-records.html +1 -1
  90. fides/ui-build/static/admin/settings/domains.html +1 -1
  91. fides/ui-build/static/admin/settings/email-templates.html +1 -1
  92. fides/ui-build/static/admin/settings/locations.html +1 -1
  93. fides/ui-build/static/admin/settings/organization.html +1 -1
  94. fides/ui-build/static/admin/settings/regulations.html +1 -1
  95. fides/ui-build/static/admin/systems/configure/[id]/test-datasets.html +1 -1
  96. fides/ui-build/static/admin/systems/configure/[id].html +1 -1
  97. fides/ui-build/static/admin/systems.html +1 -1
  98. fides/ui-build/static/admin/taxonomy.html +1 -1
  99. fides/ui-build/static/admin/user-management/new.html +1 -1
  100. fides/ui-build/static/admin/user-management/profile/[id].html +1 -1
  101. fides/ui-build/static/admin/user-management.html +1 -1
  102. fides/ui-build/static/admin/_next/static/chunks/1150-2642cd9cdc8a52f6.js +0 -1
  103. fides/ui-build/static/admin/_next/static/chunks/1376-03e7f50e708b7589.js +0 -1
  104. fides/ui-build/static/admin/_next/static/chunks/6315-1adb10a8b98b4a13.js +0 -1
  105. fides/ui-build/static/admin/_next/static/chunks/pages/data-discovery/action-center-719949074f10bd6e.js +0 -1
  106. fides/ui-build/static/admin/_next/static/chunks/pages/data-discovery/activity-4892603e743cd6ab.js +0 -1
  107. fides/ui-build/static/admin/_next/static/chunks/pages/messaging-1e60754abec1ee6b.js +0 -1
  108. fides/ui-build/static/admin/_next/static/chunks/pages/privacy-requests/configure/messaging-5e2687ab5ab10275.js +0 -1
  109. fides/ui-build/static/admin/_next/static/chunks/pages/privacy-requests/configure/storage-2914aade73dcaecc.js +0 -1
  110. fides/ui-build/static/admin/_next/static/chunks/pages/settings/consent-3ac1e5d3de5dd4a7.js +0 -1
  111. fides/ui-build/static/admin/_next/static/chunks/pages/settings/domains-24cba38685dc872c.js +0 -1
  112. fides/ui-build/static/admin/_next/static/chunks/pages/user-management/profile/[id]-c0378fd1a26a71da.js +0 -1
  113. {ethyca_fides-2.58.2b3.dist-info → ethyca_fides-2.58.2b5.dist-info/licenses}/LICENSE +0 -0
  114. {ethyca_fides-2.58.2b3.dist-info → ethyca_fides-2.58.2b5.dist-info}/top_level.txt +0 -0
  115. /fides/ui-build/static/admin/_next/static/{S7gURhIaHGAv7MFBTEOOS → _o6WH0hDzNEhnUJyvLex7}/_ssgManifest.js +0 -0
  116. /fides/ui-build/static/admin/_next/static/chunks/{1817-f82105a9608bba1a.js → 1817-48e1c9d3504e18f0.js} +0 -0
  117. /fides/ui-build/static/admin/_next/static/chunks/{6954-baa1d873abfe8b77.js → 6954-ec5276bb464d42b2.js} +0 -0
  118. /fides/ui-build/static/admin/_next/static/chunks/pages/{privacy-requests-b0f801d66e79a31a.js → privacy-requests-fd81714d811db7b3.js} +0 -0
  119. /fides/ui-build/static/admin/_next/static/chunks/pages/{user-management-3ca3c687e72d1364.js → user-management-a1db56f1cbfba373.js} +0 -0
@@ -20,6 +20,7 @@ from fides.api.models import (
20
20
  from fides.api.models.location_regulation_selections import PrivacyNoticeRegion
21
21
  from fides.api.models.privacy_notice import PrivacyNotice
22
22
  from fides.api.models.property import Property
23
+ from fides.api.models.tcf_publisher_restrictions import TCFConfiguration
23
24
  from fides.api.schemas.language import SupportedLanguage
24
25
 
25
26
 
@@ -228,6 +229,13 @@ class PrivacyExperienceConfig(PrivacyExperienceConfigBase, Base):
228
229
  EnumColumn(RejectAllMechanism),
229
230
  nullable=True,
230
231
  )
232
+ # Optional FK to a TCF Configuration
233
+
234
+ tcf_configuration_id = Column(
235
+ String,
236
+ ForeignKey(TCFConfiguration.id_field_path, ondelete="SET NULL"),
237
+ nullable=True,
238
+ )
231
239
 
232
240
  # Relationships
233
241
  experiences = relationship(
@@ -258,6 +266,12 @@ class PrivacyExperienceConfig(PrivacyExperienceConfigBase, Base):
258
266
  lazy="selectin",
259
267
  )
260
268
 
269
+ tcf_configuration: RelationshipProperty[Optional[TCFConfiguration]] = relationship(
270
+ "TCFConfiguration",
271
+ back_populates="privacy_experience_configs",
272
+ lazy="selectin",
273
+ )
274
+
261
275
  @property
262
276
  def regions(self) -> List[PrivacyNoticeRegion]:
263
277
  """Return the regions using this experience config"""
@@ -548,6 +562,17 @@ class PrivacyExperienceConfigHistory(
548
562
  EnumColumn(RejectAllMechanism),
549
563
  nullable=True,
550
564
  )
565
+ # Optional FK to a TCF Configuration
566
+ tcf_configuration_id = Column(
567
+ String,
568
+ ForeignKey(TCFConfiguration.id_field_path, ondelete="SET NULL"),
569
+ nullable=True,
570
+ )
571
+
572
+ tcf_configuration: RelationshipProperty[Optional[TCFConfiguration]] = relationship(
573
+ "TCFConfiguration",
574
+ lazy="selectin",
575
+ )
551
576
 
552
577
  version = Column(Float, nullable=False, default=1.0)
553
578
 
@@ -611,6 +636,7 @@ class PrivacyExperience(Base):
611
636
  tcf_special_features: List = []
612
637
  tcf_system_consents: List = []
613
638
  tcf_system_legitimate_interests: List = []
639
+ tcf_publisher_restrictions: List = []
614
640
  gvl: Optional[Dict] = {}
615
641
  # TCF Developer-Friendly Meta added at runtime as the result of build_tc_data_for_mobile
616
642
  meta: Dict = {}
@@ -1,5 +1,5 @@
1
1
  from enum import Enum
2
- from typing import Any, Dict, List, Optional
2
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
3
3
 
4
4
  from pydantic import BaseModel, Field, ValidationError, model_validator
5
5
  from sqlalchemy import Column
@@ -8,10 +8,13 @@ from sqlalchemy import ForeignKey, Index, Integer, String, insert, select, updat
8
8
  from sqlalchemy.dialects.postgresql import ARRAY, JSONB
9
9
  from sqlalchemy.ext.asyncio import AsyncSession
10
10
  from sqlalchemy.ext.declarative import declared_attr
11
- from sqlalchemy.orm import Session
11
+ from sqlalchemy.orm import Session, relationship
12
12
 
13
13
  from fides.api.db.base_class import Base
14
14
 
15
+ if TYPE_CHECKING:
16
+ from fides.api.models.privacy_experience import PrivacyExperienceConfig
17
+
15
18
 
16
19
  class TCFRestrictionType(str, Enum):
17
20
  """Enum for TCF restriction types"""
@@ -46,14 +49,14 @@ class RangeEntry(BaseModel):
46
49
  @model_validator(mode="after")
47
50
  def validate_vendor_range(self) -> "RangeEntry":
48
51
  """Validates that end_vendor_id is greater than start_vendor_id if present."""
49
- if (
50
- self.end_vendor_id is not None
51
- and self.end_vendor_id <= self.start_vendor_id
52
- ):
53
- raise ValueError("end_vendor_id must be greater than start_vendor_id")
52
+ if self.end_vendor_id is not None and self.end_vendor_id < self.start_vendor_id:
53
+ raise ValueError(
54
+ "end_vendor_id must be greater than or equal to start_vendor_id"
55
+ )
54
56
  return self
55
57
 
56
- def get_end(self) -> int:
58
+ @property
59
+ def effective_end_vendor_id(self) -> int:
57
60
  """Get the effective end of the range."""
58
61
  return self.end_vendor_id or self.start_vendor_id
59
62
 
@@ -68,7 +71,7 @@ class RangeEntry(BaseModel):
68
71
  second = other if self.start_vendor_id <= other.start_vendor_id else self
69
72
 
70
73
  # If first range's end is >= second range's start, they overlap
71
- return first.get_end() >= second.start_vendor_id
74
+ return first.effective_end_vendor_id >= second.start_vendor_id
72
75
 
73
76
 
74
77
  class TCFConfiguration(Base):
@@ -82,6 +85,12 @@ class TCFConfiguration(Base):
82
85
 
83
86
  name = Column(String, nullable=False, index=True, unique=True)
84
87
 
88
+ privacy_experience_configs = relationship(
89
+ "PrivacyExperienceConfig",
90
+ back_populates="tcf_configuration",
91
+ lazy="selectin",
92
+ )
93
+
85
94
 
86
95
  class TCFPublisherRestriction(Base):
87
96
  """
@@ -134,9 +143,20 @@ class TCFPublisherRestriction(Base):
134
143
  ) -> None:
135
144
  """
136
145
  Validates that if vendor_restriction is restrict_all_vendors, then the entries list is empty.
146
+ If vendor_restriction is not restrict_all_vendors, then there must be at least one entry.
137
147
  """
138
148
  if vendor_restriction == TCFVendorRestriction.restrict_all_vendors and entries:
139
- raise ValueError("restrict_all_vendors cannot have any range entries")
149
+ raise ValueError(
150
+ "A restrict_all_vendors restriction cannot have any range entries"
151
+ )
152
+
153
+ if (
154
+ vendor_restriction != TCFVendorRestriction.restrict_all_vendors
155
+ and not entries
156
+ ):
157
+ raise ValueError(
158
+ f"A {vendor_restriction} restriction must have at least one range entry"
159
+ )
140
160
 
141
161
  @staticmethod
142
162
  def check_for_overlaps(entries: List[RangeEntry]) -> None:
@@ -186,17 +206,19 @@ class TCFPublisherRestriction(Base):
186
206
  return data
187
207
 
188
208
  @classmethod
189
- async def validate_vendor_overlaps_for_purpose(
209
+ async def check_for_restriction_conflicts(
190
210
  cls,
211
+ *,
191
212
  async_db: AsyncSession,
192
213
  configuration_id: str,
193
214
  purpose_id: int,
194
- new_data: dict,
215
+ restriction_type: TCFRestrictionType,
216
+ vendor_restriction: TCFVendorRestriction,
217
+ range_entries: Optional[list[RangeEntry]] = None,
218
+ restriction_id: Optional[str] = None,
195
219
  ) -> None:
196
220
  """
197
- Validates that the new vendor ranges do not overlap with any existing vendor ranges for the purpose
198
- in the given configuration.
199
- Raises a ValueError if any vendor ranges overlap.
221
+ Checks that the new restriction data does not conflict with any existing restrictions for the purpose.
200
222
  """
201
223
  # First, get all the restrictions for the purpose in the given configuration
202
224
  query = (
@@ -204,28 +226,145 @@ class TCFPublisherRestriction(Base):
204
226
  .where(cls.tcf_configuration_id == configuration_id)
205
227
  .where(cls.purpose_id == purpose_id)
206
228
  )
207
- restrictions = await async_db.execute(query)
208
- restrictions = restrictions.scalars().all()
209
-
210
- # If we have an existing id, we need to exclude the current restriction from the list of existing restrictions
211
- # so that the new_data restrictions don't overlap with themselves
212
- if "id" in new_data:
213
- existing_entries = [
214
- entry
215
- for r in restrictions
216
- for entry in r.range_entries
217
- if r.id != new_data["id"]
218
- ]
219
- else:
220
- existing_entries = [
221
- entry for r in restrictions for entry in r.range_entries
222
- ]
229
+ # If we're updating an existing restriction, exclude it from the list of
230
+ # restrictions so it doesn't conflict with itself
231
+ if restriction_id:
232
+ query = query.where(cls.id != restriction_id)
223
233
 
224
- all_entries = [*existing_entries, *new_data.get("range_entries", [])]
225
- cls.check_for_overlaps(
226
- [RangeEntry.model_validate(entry) for entry in all_entries]
234
+ restrictions_result = await async_db.execute(query)
235
+ relevant_restrictions = restrictions_result.scalars().all()
236
+
237
+ if any(
238
+ restriction.vendor_restriction == TCFVendorRestriction.restrict_all_vendors
239
+ for restriction in relevant_restrictions
240
+ ):
241
+ raise ValueError(
242
+ f"Invalid restriction for purpose {purpose_id}: a restrict_all_vendors restriction exists for this purpose."
243
+ )
244
+
245
+ # If we're creating a restrict_all_vendors restriction,
246
+ # then there should be no other restrictions for this purpose
247
+ if vendor_restriction == TCFVendorRestriction.restrict_all_vendors:
248
+ if relevant_restrictions:
249
+ raise ValueError(
250
+ f"Invalid restrict_all_vendors restriction for purpose {purpose_id}: other restrictions already exist for this purpose."
251
+ )
252
+
253
+ return None
254
+
255
+ # If we already have a restriction for that restriction type,
256
+ # then we raise an error
257
+ if any(
258
+ restriction.restriction_type == restriction_type
259
+ for restriction in relevant_restrictions
260
+ ):
261
+ raise ValueError(
262
+ f"Invalid {restriction_type} restriction for purpose {purpose_id}: a restriction of this type already exists for this purpose."
263
+ )
264
+
265
+ # We now need to check for vendor overlaps between all the restrictions.
266
+ # To achieve this, we need to transform allowlist-style restrictions into
267
+ # actual restriction ranges (rather than "allow" ranges).
268
+ new_range_entries = (
269
+ cls.transform_allowlist_restriction(range_entries or [])
270
+ if vendor_restriction == TCFVendorRestriction.allow_specific_vendors
271
+ else (range_entries or [])
227
272
  )
228
273
 
274
+ existing_range_entries = []
275
+ for restriction in relevant_restrictions:
276
+ range_entries = [
277
+ RangeEntry.model_validate(entry) for entry in restriction.range_entries
278
+ ]
279
+ transformed_range_entries = (
280
+ cls.transform_allowlist_restriction(range_entries)
281
+ if restriction.vendor_restriction
282
+ == TCFVendorRestriction.allow_specific_vendors
283
+ else range_entries
284
+ )
285
+ existing_range_entries.extend(transformed_range_entries)
286
+
287
+ all_entries = [*existing_range_entries, *new_range_entries]
288
+ cls.check_for_overlaps(all_entries)
289
+
290
+ @classmethod
291
+ def transform_allowlist_restriction(
292
+ cls, range_entries: list[RangeEntry]
293
+ ) -> list[RangeEntry]:
294
+ """
295
+ Transform allowlist-style restrictions into restriction ranges.
296
+ E.g if you have an "allow specific vendors" restriction with the range_entries
297
+ [
298
+ {start_vendor_id: 5, end_vendor_id: 10},
299
+ {start_vendor_id: 25, end_vendor_id: 40},
300
+ {start_vendor_id: 123 },
301
+ {start_vendor_id: 345, end_vendor_id: 380},
302
+ ],
303
+ the transformed restriction ranges would be:
304
+ [
305
+ {start_vendor_id: 1, end_vendor_id: 4},
306
+ {start_vendor_id: 11, end_vendor_id: 24},
307
+ {start_vendor_id: 41, end_vendor_id: 122},
308
+ {start_vendor_id: 124, end_vendor_id: MAX_GVL_ID},
309
+ ]
310
+ """
311
+ MAX_GVL_ID = 9999 # TODO: get this from the TCF spec
312
+
313
+ # First, we need to sort the range_entries by start_vendor_id
314
+ sorted_range_entries = sorted(range_entries, key=lambda x: x.start_vendor_id)
315
+
316
+ # Now, we need to transform the allowlist-style restrictions into
317
+ # actual restriction ranges.
318
+ transformed_range_entries: list[RangeEntry] = []
319
+
320
+ total_entries = len(sorted_range_entries)
321
+
322
+ # This shouldn't happen, but just in case
323
+ if total_entries == 0:
324
+ raise ValueError(
325
+ "No range entries found for allow_specific_vendors restriction"
326
+ )
327
+
328
+ # If the first range entry starts at a number greater than 1,
329
+ # we need to add a transformed range entry from 1 up to the entry's start
330
+ if sorted_range_entries[0].start_vendor_id > 1:
331
+ transformed_range_entries.append(
332
+ RangeEntry(
333
+ start_vendor_id=1,
334
+ end_vendor_id=sorted_range_entries[0].start_vendor_id - 1,
335
+ )
336
+ )
337
+
338
+ # Iterate through the sorted range_entries and transform them into restriction ranges
339
+ for idx, range_entry in enumerate(sorted_range_entries):
340
+ # For all but the last range entry, we add an entry that corresponds to the numbers
341
+ # between the end of the current range entry and the start of the next range entry
342
+ if idx < total_entries - 1:
343
+ # Only create a range entry if there's actually a gap between the ranges
344
+ next_start = sorted_range_entries[idx + 1].start_vendor_id
345
+ current_end = range_entry.effective_end_vendor_id
346
+ if next_start > current_end + 1:
347
+ transformed_range_entries.append(
348
+ RangeEntry(
349
+ start_vendor_id=current_end + 1,
350
+ end_vendor_id=next_start - 1,
351
+ )
352
+ )
353
+
354
+ # If the last range entry ends at a number less than MAX_GVL_ID,
355
+ # we need to add a transformed range entry from the last entry's end to MAX_GVL_ID
356
+ if sorted_range_entries[-1].effective_end_vendor_id < MAX_GVL_ID:
357
+ transformed_range_entries.append(
358
+ RangeEntry(
359
+ start_vendor_id=sorted_range_entries[-1].effective_end_vendor_id
360
+ + 1,
361
+ end_vendor_id=MAX_GVL_ID,
362
+ )
363
+ )
364
+
365
+ # Return the transformed restriction entries
366
+ return transformed_range_entries
367
+
229
368
  @classmethod
230
369
  def create(
231
370
  cls,
@@ -249,6 +388,17 @@ class TCFPublisherRestriction(Base):
249
388
 
250
389
  data = cls.validate_publisher_restriction_data(data)
251
390
 
391
+ await cls.check_for_restriction_conflicts(
392
+ async_db=async_db,
393
+ configuration_id=data["tcf_configuration_id"],
394
+ purpose_id=data["purpose_id"],
395
+ restriction_type=TCFRestrictionType(data["restriction_type"]),
396
+ vendor_restriction=TCFVendorRestriction(data["vendor_restriction"]),
397
+ range_entries=[
398
+ RangeEntry.model_validate(entry) for entry in data["range_entries"]
399
+ ],
400
+ )
401
+
252
402
  values = {
253
403
  "tcf_configuration_id": data["tcf_configuration_id"],
254
404
  "purpose_id": data["purpose_id"],
@@ -257,14 +407,6 @@ class TCFPublisherRestriction(Base):
257
407
  "range_entries": data["range_entries"],
258
408
  }
259
409
 
260
- # Validate that the new vendor ranges do not overlap with any existing vendor ranges for the purpose
261
- await cls.validate_vendor_overlaps_for_purpose(
262
- async_db=async_db,
263
- configuration_id=data["tcf_configuration_id"],
264
- purpose_id=data["purpose_id"],
265
- new_data=values,
266
- )
267
-
268
410
  # Insert the new restriction
269
411
  insert_stmt = insert(cls).values(values) # type: ignore[arg-type]
270
412
  result = await async_db.execute(insert_stmt)
@@ -280,22 +422,41 @@ class TCFPublisherRestriction(Base):
280
422
  Update a TCFPublisherRestriction with the data.
281
423
  Validates the data and checks for vendor overlaps.
282
424
  """
283
- # Validate the data on its own
284
- data = self.validate_publisher_restriction_data(data)
425
+ # Create a new dict merging the existing data and the updated data
426
+ updated_data = {
427
+ "id": self.id,
428
+ "tcf_configuration_id": self.tcf_configuration_id,
429
+ "purpose_id": self.purpose_id,
430
+ "restriction_type": self.restriction_type,
431
+ "vendor_restriction": self.vendor_restriction,
432
+ "range_entries": self.range_entries,
433
+ **data,
434
+ }
285
435
 
286
- # Validate that the new vendor ranges do not overlap with any existing vendor ranges for the purpose
287
- await self.validate_vendor_overlaps_for_purpose(
436
+ # First validate the data on its own
437
+ data = self.validate_publisher_restriction_data(updated_data)
438
+
439
+ # Then check for conflicts
440
+ await self.check_for_restriction_conflicts(
288
441
  async_db=async_db,
289
442
  configuration_id=self.tcf_configuration_id,
290
443
  purpose_id=self.purpose_id,
291
- new_data={**data, "id": self.id}, # Pass in id explicitly
444
+ restriction_type=TCFRestrictionType(data["restriction_type"]),
445
+ vendor_restriction=TCFVendorRestriction(data["vendor_restriction"]),
446
+ range_entries=[
447
+ RangeEntry.model_validate(entry) for entry in data["range_entries"]
448
+ ],
449
+ restriction_id=self.id,
292
450
  )
293
451
 
452
+ # Remove the id from the updated
453
+ updated_data.pop("id")
454
+
294
455
  # Finally, make the update
295
456
  update_query = (
296
457
  update(TCFPublisherRestriction) # type: ignore[arg-type]
297
458
  .where(TCFPublisherRestriction.id == self.id)
298
- .values(**data)
459
+ .values(**updated_data)
299
460
  )
300
461
  await async_db.execute(update_query)
301
462
  await async_db.commit()
@@ -122,6 +122,7 @@ def extract_key_for_address(
122
122
  return f"{dataset}:{collection}"
123
123
 
124
124
 
125
+ # pylint: disable=too-many-branches
125
126
  def unflatten_dict(flat_dict: Dict[str, Any], separator: str = ".") -> Dict[str, Any]:
126
127
  """
127
128
  Converts a dictionary of paths/values into a nested dictionary
@@ -149,17 +150,29 @@ def unflatten_dict(flat_dict: Dict[str, Any], separator: str = ".") -> Dict[str,
149
150
  for i, current_key in enumerate(keys[:-1]):
150
151
  next_key = keys[i + 1]
151
152
  if next_key.isdigit():
152
- target = target.setdefault(current_key, [])
153
+ if isinstance(target, dict): # Only call setdefault on dictionaries
154
+ target = target.setdefault(current_key, [])
155
+ elif isinstance(
156
+ target, list
157
+ ): # If target is a list, handle differently
158
+ idx = int(current_key)
159
+ while len(target) <= idx:
160
+ target.append([]) # Add a list since next_key is a digit
161
+ target = target[idx]
153
162
  else:
154
163
  if isinstance(target, dict):
155
164
  target = target.setdefault(current_key, {})
156
165
  elif isinstance(target, list):
157
- while len(target) <= int(current_key):
166
+ idx = int(current_key)
167
+ while len(target) <= idx:
158
168
  target.append({})
159
- target = target[int(current_key)]
169
+ target = target[idx]
160
170
  try:
161
171
  if isinstance(target, list):
162
- target.append(value)
172
+ idx = int(keys[-1]) if keys[-1].isdigit() else len(target)
173
+ while len(target) <= idx:
174
+ target.append(None)
175
+ target[idx] = value
163
176
  else:
164
177
  # If the value is a dictionary, add its components to the queue for processing
165
178
  if isinstance(value, dict):
@@ -176,10 +189,12 @@ def unflatten_dict(flat_dict: Dict[str, Any], separator: str = ".") -> Dict[str,
176
189
  return output
177
190
 
178
191
 
192
+ # pylint: disable=too-many-branches
179
193
  def flatten_dict(data: Any, prefix: str = "", separator: str = ".") -> Dict[str, Any]:
180
194
  """
181
195
  Recursively flatten a dictionary or list into a flat dictionary with dot-notation keys.
182
196
  Handles nested dictionaries and arrays with proper indices.
197
+ Preserves empty lists and dictionaries.
183
198
 
184
199
  example:
185
200
 
@@ -191,7 +206,9 @@ def flatten_dict(data: Any, prefix: str = "", separator: str = ".") -> Dict[str,
191
206
  "D": [
192
207
  {"E": "3"},
193
208
  {"E": "4"}
194
- ]
209
+ ],
210
+ "E": [],
211
+ "F": {}
195
212
  }
196
213
 
197
214
  becomes
@@ -200,7 +217,9 @@ def flatten_dict(data: Any, prefix: str = "", separator: str = ".") -> Dict[str,
200
217
  "A.B": "1",
201
218
  "A.C": "2",
202
219
  "D.0.E": "3",
203
- "D.1.E": "4"
220
+ "D.1.E": "4",
221
+ "E": [],
222
+ "F": {}
204
223
  }
205
224
 
206
225
  Args:
@@ -211,20 +230,40 @@ def flatten_dict(data: Any, prefix: str = "", separator: str = ".") -> Dict[str,
211
230
  Returns:
212
231
  A flattened dictionary with dot-notation keys
213
232
  """
214
- items = {}
233
+ items: Dict[str, Any] = {}
215
234
 
216
235
  if isinstance(data, dict):
236
+ # Handle top-level empty dictionary case
237
+ if not data and not prefix:
238
+ return {}
239
+
240
+ # If the dictionary is empty but has a prefix, store it as is
241
+ if not data:
242
+ items[prefix] = {}
243
+ return items
244
+
217
245
  for k, v in data.items():
218
246
  new_key = f"{prefix}{separator}{k}" if prefix else k
219
247
  if isinstance(v, (dict, list)):
220
- items.update(flatten_dict(v, new_key, separator))
248
+ if not v: # Handle empty dict or list
249
+ items[new_key] = v
250
+ else:
251
+ items.update(flatten_dict(v, new_key, separator))
221
252
  else:
222
253
  items[new_key] = v
223
254
  elif isinstance(data, list):
255
+ # If the list is empty, store it as is
256
+ if not data:
257
+ items[prefix] = []
258
+ return items
259
+
224
260
  for i, v in enumerate(data):
225
261
  new_key = f"{prefix}{separator}{i}"
226
262
  if isinstance(v, (dict, list)):
227
- items.update(flatten_dict(v, new_key, separator))
263
+ if not v: # Handle empty dict or list
264
+ items[new_key] = v
265
+ else:
266
+ items.update(flatten_dict(v, new_key, separator))
228
267
  else:
229
268
  items[new_key] = v
230
269
  else:
@@ -5,9 +5,11 @@ from click_default_group import DefaultGroup
5
5
 
6
6
  from fides.cli.options import fides_key_argument, manifests_dir_argument
7
7
  from fides.cli.utils import with_analytics, with_server_health_check
8
- from fides.common.utils import echo_red
8
+ from fides.common.utils import echo_green, echo_red
9
9
  from fides.core import parse as _parse
10
10
  from fides.core import pull as _pull
11
+ from fides.core.api_helpers import list_server_resources
12
+ from fides.core.pull import remove_nulls, write_manifest_file
11
13
  from fides.core.utils import git_is_dirty
12
14
 
13
15
 
@@ -60,26 +62,88 @@ def pull_all(
60
62
 
61
63
  @pull.command(name="dataset") # type: ignore
62
64
  @click.pass_context
63
- @fides_key_argument
65
+ @click.argument("fides_key", required=False)
64
66
  @manifests_dir_argument
65
- def dataset(
67
+ @click.option(
68
+ "--all-resources",
69
+ "-a",
70
+ is_flag=True,
71
+ default=False,
72
+ help="Pull all datasets from the server.",
73
+ )
74
+ @click.option(
75
+ "--separate-files",
76
+ is_flag=True,
77
+ default=False,
78
+ help="Write each dataset to a separate file named after its fides_key.",
79
+ )
80
+ @with_analytics
81
+ @with_server_health_check
82
+ def pull_dataset(
66
83
  ctx: click.Context,
67
- fides_key: str,
84
+ fides_key: Optional[str],
68
85
  manifests_dir: str,
86
+ all_resources: bool,
87
+ separate_files: bool,
69
88
  ) -> None:
70
89
  """
71
- Retrieve a specific dataset from the server and update the local manifest files.
90
+ Retrieve datasets from the server and update the local manifest files.
91
+
92
+ If FIDES_KEY is provided, only that dataset will be pulled.
93
+ If --all-resources is specified, all datasets will be pulled.
94
+ If --separate-files is specified, each dataset will be written to a separate file.
72
95
  """
96
+ if not fides_key and not all_resources:
97
+ echo_red("Error: Either FIDES_KEY or --all-resources must be specified.")
98
+ raise SystemExit(1)
73
99
 
74
100
  config = ctx.obj["CONFIG"]
75
- _pull.pull(
76
- url=config.cli.server_url,
77
- manifests_dir=manifests_dir,
78
- headers=config.user.auth_header,
79
- fides_key=fides_key,
80
- resource_type="dataset",
81
- all_resources_file=None,
82
- )
101
+
102
+ # Check for unstaged git changes before proceeding
103
+ if git_is_dirty(manifests_dir):
104
+ echo_red(
105
+ f"There are unstaged changes in your manifest directory: '{manifests_dir}' \nAborting pull!"
106
+ )
107
+ raise SystemExit(1)
108
+
109
+ if fides_key:
110
+ _pull.pull(
111
+ url=config.cli.server_url,
112
+ manifests_dir=manifests_dir,
113
+ headers=config.user.auth_header,
114
+ fides_key=fides_key,
115
+ resource_type="dataset",
116
+ all_resources_file=None,
117
+ )
118
+ elif all_resources:
119
+ # Get all available datasets from server
120
+ datasets = list_server_resources(
121
+ url=config.cli.server_url,
122
+ headers=config.user.auth_header,
123
+ resource_type="dataset",
124
+ exclude_keys=[],
125
+ )
126
+
127
+ if not datasets:
128
+ echo_red("No datasets found on the server.")
129
+ return
130
+
131
+ # Remove null values
132
+ datasets = [remove_nulls(dataset) for dataset in datasets]
133
+
134
+ if separate_files:
135
+ # Write each dataset to a separate file
136
+ for dataset in datasets:
137
+ if "fides_key" in dataset:
138
+ fides_key = dataset["fides_key"]
139
+ manifest_path = f"{manifests_dir.rstrip('/')}/{fides_key}.yml"
140
+ write_manifest_file(manifest_path, {"dataset": [dataset]})
141
+ else:
142
+ # Write all datasets to a single file
143
+ all_datasets_file = f"{manifests_dir.rstrip('/')}/datasets.yml"
144
+ write_manifest_file(all_datasets_file, {"dataset": datasets})
145
+
146
+ echo_green("Pull complete.")
83
147
 
84
148
 
85
149
  @pull.command(name="system") # type: ignore
fides/core/api.py CHANGED
@@ -4,7 +4,8 @@ from typing import Dict, List, Optional, Union
4
4
 
5
5
  import requests
6
6
 
7
- from fides.api.util.endpoint_utils import API_PREFIX
7
+ # Not using the constant value from fides.api.util.endpoint_utils to reduce the startup time for the CLI
8
+ API_PREFIX = "/api/v1"
8
9
 
9
10
 
10
11
  def generate_resource_url(