Skip to content

Commit 88f48c5

Browse files
committed
feat: replace private by published_at
1 parent a0410f2 commit 88f48c5

15 files changed

Lines changed: 179 additions & 36 deletions

udata/core/dataset/api.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,10 @@ def parse_filters(datasets, args):
254254
if args.get("private") is not None:
255255
if current_user.is_anonymous:
256256
abort(401)
257-
datasets = datasets.filter(private=args["private"])
257+
if args["private"]:
258+
datasets = datasets.filter(published_at=None)
259+
else:
260+
datasets = datasets.filter(published_at__ne=None)
258261
return datasets
259262

260263

@@ -306,9 +309,7 @@ class DatasetListAPI(API):
306309
def get(self):
307310
"""List or search all datasets"""
308311
args = dataset_parser.parse()
309-
datasets = Dataset.objects.visible_by_user(
310-
current_user, mongoengine.Q(private__ne=True, archived=None, deleted=None)
311-
)
312+
datasets = Dataset.objects.visible_by_user(current_user)
312313
datasets = dataset_parser.parse_filters(datasets, args)
313314
sort = args["sort"] or ("$text_score" if args["q"] else None) or DEFAULT_SORTING
314315
return datasets.order_by(sort).paginate(args["page"], args["page_size"])
@@ -829,7 +830,7 @@ class DatasetSuggestAPI(API):
829830
def get(self):
830831
"""Datasets suggest endpoint using mongoDB contains"""
831832
args = suggest_parser.parse_args()
832-
datasets_query = Dataset.objects(archived=None, deleted=None, private=False)
833+
datasets_query = Dataset.objects.visible()
833834
datasets = datasets_query.filter(
834835
Q(title__icontains=args["q"]) | Q(acronym__icontains=args["q"])
835836
)

udata/core/dataset/api_fields.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@
274274
"last_modified",
275275
"deleted",
276276
"private",
277+
"published_at",
277278
"tags",
278279
"badges",
279280
"resources",
@@ -349,7 +350,10 @@
349350
"archived": fields.ISODateTime(description="The archival date if archived"),
350351
"featured": fields.Boolean(description="Is the dataset featured"),
351352
"private": fields.Boolean(
352-
description="Is the dataset private to the owner or the organization"
353+
description="Is the dataset private (DEPRECATED: use published_at instead)"
354+
),
355+
"published_at": fields.ISODateTime(
356+
description="Publication date (null if unpublished/private)"
353357
),
354358
"tags": fields.List(fields.String),
355359
"badges": fields.List(

udata/core/dataset/apiv2.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"last_modified",
5151
"deleted",
5252
"private",
53+
"published_at",
5354
"tags",
5455
"badges",
5556
"resources",
@@ -125,7 +126,10 @@
125126
"archived": fields.ISODateTime(description="The archival date if archived"),
126127
"featured": fields.Boolean(description="Is the dataset featured"),
127128
"private": fields.Boolean(
128-
description="Is the dataset private to the owner or the organization"
129+
description="Is the dataset private (DEPRECATED: use published_at instead)"
130+
),
131+
"published_at": fields.ISODateTime(
132+
description="Publication date (null if unpublished/private)"
129133
),
130134
"tags": fields.List(fields.String),
131135
"badges": fields.List(
@@ -317,9 +321,7 @@ class DatasetListAPI(API):
317321
def get(self):
318322
"""List or search all datasets"""
319323
args = dataset_parser.parse()
320-
datasets = Dataset.objects.exclude("resources").visible_by_user(
321-
current_user, mongoengine.Q(private__ne=True, archived=None, deleted=None)
322-
)
324+
datasets = Dataset.objects.exclude("resources").visible_by_user(current_user)
323325
datasets = dataset_parser.parse_filters(datasets, args)
324326
sort = args["sort"] or ("$text_score" if args["q"] else None) or DEFAULT_SORTING
325327
return datasets.order_by(sort).paginate(args["page"], args["page_size"])

udata/core/dataset/csv.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class DatasetCsvAdapter(csv.Adapter):
3838
"last_modified",
3939
("tags", lambda o: ",".join(o.tags)),
4040
("archived", lambda o: o.archived or False),
41+
"published_at",
4142
("resources_count", lambda o: len(o.resources)),
4243
("main_resources_count", lambda o: len([r for r in o.resources if r.type == "main"])),
4344
("resources_formats", lambda o: ",".join(set(r.format for r in o.resources if r.format))),
@@ -71,6 +72,7 @@ class ResourcesCsvAdapter(csv.NestedAdapter):
7172
),
7273
dataset_field("license"),
7374
dataset_field("private"),
75+
dataset_field("published_at"),
7476
dataset_field("archived", lambda r: r.archived or False),
7577
)
7678
nested_fields = (

udata/core/dataset/factories.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from datetime import datetime
23
from os.path import join
34

45
import factory
@@ -19,6 +20,7 @@ class Meta:
1920
title = factory.Faker("sentence")
2021
description = factory.Faker("text")
2122
frequency = UpdateFrequency.UNKNOWN
23+
published_at = factory.LazyFunction(datetime.utcnow)
2224
resources = factory.LazyAttribute(lambda o: ResourceFactory.build_batch(o.nb_resources))
2325

2426
class Params:
@@ -31,7 +33,7 @@ class Params:
3133

3234

3335
class HiddenDatasetFactory(DatasetFactory):
34-
private = True
36+
published_at = None
3537

3638

3739
class ChecksumFactory(ModelFactory):

udata/core/dataset/forms.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import datetime
2+
13
from udata.core.access_type.constants import (
24
AccessAudienceCondition,
35
AccessAudienceType,
@@ -213,13 +215,68 @@ class DatasetForm(ModelForm):
213215
_("Private"),
214216
description=_("Restrict the dataset visibility to you or your organization only."),
215217
)
218+
published_at = fields.DateTimeField(_("Publication date"))
216219

217220
owner = fields.CurrentUserField()
218221
organization = fields.PublishAsField(_("Publish as"))
219222
extras = fields.ExtrasField()
220223
resources = fields.NestedModelList(ResourceForm)
221224
contact_points = fields.ContactPointListField(validators=[validate_contact_point])
222225

226+
# ==================================================================================
227+
# Backward compatibility layer for `private` field
228+
# ==================================================================================
229+
# The `private` boolean field has been replaced by `published_at` (datetime) on the
230+
# Dataset model. However, we need to maintain API backward compatibility.
231+
#
232+
# Challenges encountered:
233+
# 1. MongoEngine rejects unknown fields in constructor - we can't just add a
234+
# `private` property with a setter on the model because ModelForm.save() passes
235+
# self.data directly to the model constructor, which fails with FieldDoesNotExist.
236+
#
237+
# 2. WTForms BooleanField.process_formdata() doesn't update self.data when the value
238+
# is empty/None, so we can't rely on self.private.data to detect explicit None.
239+
#
240+
# 3. self.data is a read-only property computed from field values, so we can't
241+
# modify it directly (e.g., self.data.pop("private") fails).
242+
#
243+
# Solution:
244+
# - Override __init__ to populate private.data from the existing instance
245+
# - Override populate_obj to skip the private field (model has no such field)
246+
# - Override save to:
247+
# a) Check self.formdata (raw JSON dict) to detect if private was explicitly sent
248+
# b) Convert private -> published_at before creating/updating the instance
249+
# c) Exclude private from the data dict when creating new instances
250+
# ==================================================================================
251+
252+
def __init__(self, *args, **kwargs):
253+
super().__init__(*args, **kwargs)
254+
if self.instance:
255+
self.private.data = self.instance.published_at is None
256+
257+
def populate_obj(self, obj):
258+
for name, field in self._fields.items():
259+
if name != "private":
260+
field.populate_obj(obj, name)
261+
262+
def save(self, commit=True, **kwargs):
263+
if self.formdata and "private" in self.formdata:
264+
private_value = self.formdata.get("private")
265+
if private_value is True:
266+
self.published_at.data = None
267+
elif private_value is False or private_value is None:
268+
self.published_at.data = datetime.utcnow()
269+
270+
if self.instance:
271+
self.populate_obj(self.instance)
272+
else:
273+
data = {k: v for k, v in self.data.items() if k != "private"}
274+
self.instance = self.model_class(**data)
275+
276+
if commit:
277+
self.instance.save(**kwargs)
278+
return self.instance
279+
223280

224281
class ResourcesListForm(ModelForm):
225282
model_class = Dataset

udata/core/dataset/models.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -340,10 +340,15 @@ def default(cls):
340340

341341
class DatasetQuerySet(OwnedQuerySet):
342342
def visible(self):
343-
return self(private__ne=True, deleted=None, archived=None)
343+
return self(published_at__ne=None, deleted=None, archived=None)
344344

345345
def hidden(self):
346-
return self(db.Q(private=True) | db.Q(deleted__ne=None) | db.Q(archived__ne=None))
346+
return self(db.Q(published_at=None) | db.Q(deleted__ne=None) | db.Q(archived__ne=None))
347+
348+
def visible_by_user(self, user):
349+
return super().visible_by_user(
350+
user, db.Q(published_at__ne=None, archived=None, deleted=None)
351+
)
347352

348353
def with_badge(self, kind):
349354
return self(badges__kind=kind)
@@ -553,7 +558,7 @@ class Dataset(
553558
tags = field(db.TagListField())
554559
resources = field(db.ListField(db.EmbeddedDocumentField(Resource)), auditable=False)
555560

556-
private = field(db.BooleanField(default=False))
561+
published_at = field(db.DateTimeField())
557562

558563
frequency = field(db.EnumField(UpdateFrequency))
559564
frequency_date = field(db.DateTimeField(verbose_name=_("Future date of update")))
@@ -600,6 +605,11 @@ class Dataset(
600605
def __str__(self):
601606
return self.title or ""
602607

608+
def to_dict(self, exclude=None):
609+
data = super().to_dict(exclude=exclude)
610+
data["private"] = self.private
611+
return data
612+
603613
__metrics_keys__ = [
604614
"discussions",
605615
"discussions_open",
@@ -743,7 +753,12 @@ def is_visible(self):
743753

744754
@property
745755
def is_hidden(self):
746-
return self.private or self.deleted or self.archived
756+
return self.published_at is None or self.deleted or self.archived
757+
758+
@property
759+
def private(self) -> bool:
760+
"""Computed property for backward compatibility."""
761+
return self.published_at is None
747762

748763
@property
749764
def full_title(self):

udata/core/dataset/tasks.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,15 @@ def get_queryset(model_cls):
9191
if model_cls.__name__ == "Resource":
9292
model_cls = getattr(udata_models, "Dataset")
9393
params = {}
94-
attrs = ("private", "deleted", "deleted_at")
95-
for attr in attrs:
94+
# Dataset uses published_at instead of private; other models still use private
95+
if model_cls.__name__ == "Dataset":
96+
params["published_at__ne"] = None
97+
elif getattr(model_cls, "private", None):
98+
params["private"] = False
99+
# Filter out deleted/soft-deleted items
100+
for attr in ("deleted", "deleted_at"):
96101
if getattr(model_cls, attr, None):
97-
params[attr] = False
102+
params[attr] = None
98103
# no_cache to avoid eating up too much RAM
99104
return model_cls.objects.filter(**params).no_cache()
100105

udata/core/organization/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ def get(self, org):
580580
args = dataset_parser.parse()
581581
qs = Dataset.objects.owned_by(org)
582582
if not OrganizationPrivatePermission(org).can():
583-
qs = qs(private__ne=True)
583+
qs = qs(published_at__ne=None)
584584
return qs.order_by(args["sort"]).paginate(args["page"], args["page_size"])
585585

586586

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
Migration: Add published_at field and populate from private field history.
3+
4+
Phase 1 (fast): Bulk update all public datasets with created_at_internal
5+
Phase 2 (slow): Refine dates using activity history for more accuracy
6+
"""
7+
import logging
8+
9+
import click
10+
11+
from udata.core.dataset.activities import UserUpdatedDataset
12+
from udata.models import Dataset
13+
14+
log = logging.getLogger(__name__)
15+
16+
17+
def migrate(db):
18+
log.info("Phase 1: Bulk update with created_at_internal...")
19+
20+
# Avoid downtime: set a default value immediately so the system stays functional
21+
result = db.dataset.update_many(
22+
{"private": False, "published_at": {"$exists": False}},
23+
[{"$set": {"published_at": "$created_at_internal"}}],
24+
)
25+
log.info(f"Phase 1 done: {result.modified_count} datasets updated")
26+
27+
log.info("Phase 2: Refining dates from activity history...")
28+
29+
datasets = Dataset.objects(published_at__ne=None).only("id", "created_at_internal")
30+
count = datasets.count()
31+
updated = 0
32+
33+
with click.progressbar(
34+
datasets.no_cache().timeout(False), length=count, label="Refining dates"
35+
) as progress:
36+
for dataset in progress:
37+
activity = (
38+
UserUpdatedDataset.objects(related_to=dataset.id, changes="private")
39+
.order_by("-created_at")
40+
.only("created_at")
41+
.first()
42+
)
43+
44+
if activity and activity.created_at != dataset.created_at_internal:
45+
Dataset.objects(id=dataset.id).update_one(set__published_at=activity.created_at)
46+
updated += 1
47+
48+
log.info(f"Phase 2 done: {updated} datasets refined with activity date")

0 commit comments

Comments
 (0)