Skip to content

Commit 86916d1

Browse files
committed
Invert the Collection-Doi relationship and use DoiFactory directly in tests
1 parent cc16e5a commit 86916d1

File tree

14 files changed

+155
-86
lines changed

14 files changed

+155
-86
lines changed

isic/conftest.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
get_elasticsearch_client,
1616
maybe_create_index,
1717
)
18-
from isic.core.tests.factories import CollectionFactory, DoiFactory, ImageFactory, IsicIdFactory
18+
from isic.core.tests.factories import CollectionFactory, ImageFactory, IsicIdFactory
1919
from isic.ingest.tests.factories import (
2020
AccessionFactory,
2121
AccessionReviewFactory,
@@ -108,4 +108,3 @@ def s3ff_random_field_value(s3ff_field_value_factory):
108108
register(IsicIdFactory)
109109
register(ImageFactory)
110110
register(CollectionFactory)
111-
register(DoiFactory)

isic/core/admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ class SupplementalFileInline(admin.TabularInline):
215215
@admin.register(Doi)
216216
class DoiAdmin(StaffReadonlyAdmin):
217217
list_select_related = ["collection"]
218-
list_display = ["id", "url", "collection", "bundle", "num_supplemental_files"]
218+
list_display = ["id", "external_url", "collection", "bundle", "num_supplemental_files"]
219219
inlines = [SupplementalFileInline]
220220

221221
autocomplete_fields = ["creator"]

isic/core/api/collection.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ def query_min_length(cls, v: str):
4545
class CollectionOut(ModelSchema):
4646
class Meta:
4747
model = Collection
48-
fields = ["id", "name", "description", "public", "pinned", "locked", "doi"]
48+
fields = ["id", "name", "description", "public", "pinned", "locked"]
4949

50-
doi_url: str | None = Field(alias="doi_url")
50+
doi: str | None = Field(None, alias="doi.id")
51+
doi_url: str | None = Field(None, alias="doi.external_url")
5152

5253

5354
@router.get(

isic/core/api/doi.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ def parse_s3_file_field_values(cls, v): # noqa: N805
4141
include_in_schema=False,
4242
)
4343
def create_doi(request, payload: CreateDOIIn):
44-
collection = get_object_or_404(Collection, id=payload.collection_id)
44+
collection = get_object_or_404(
45+
Collection.objects.select_related("doi"), pk=payload.collection_id
46+
)
4547

4648
if not request.user.is_staff:
4749
return 403, {"error": "You do not have permission to create a DOI."}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import django.core.validators
2+
from django.db import migrations, models
3+
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
4+
from django.db.migrations.state import StateApps
5+
from django.db.models import OuterRef, Subquery
6+
import django.db.models.deletion
7+
8+
import isic.core.models.doi
9+
10+
11+
def invert_doi_fk(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor):
12+
Doi = apps.get_model("core", "Doi")
13+
Collection = apps.get_model("core", "Collection")
14+
15+
Doi.objects.update(
16+
collection=Subquery(Collection.objects.filter(doi=OuterRef("pk")).values("pk")[:1])
17+
)
18+
19+
20+
class Migration(migrations.Migration):
21+
dependencies = [
22+
("core", "0025_alter_collection_shares_alter_image_shares"),
23+
]
24+
25+
operations = [
26+
migrations.AlterField(
27+
model_name="collection",
28+
name="doi",
29+
field=models.OneToOneField(
30+
blank=True,
31+
null=True,
32+
on_delete=django.db.models.deletion.PROTECT,
33+
related_name="collection_reverse",
34+
to="core.doi",
35+
),
36+
),
37+
migrations.AddField(
38+
model_name="doi",
39+
name="collection",
40+
field=models.OneToOneField(
41+
default=None,
42+
null=True,
43+
on_delete=django.db.models.deletion.PROTECT,
44+
to="core.collection",
45+
),
46+
preserve_default=False,
47+
),
48+
migrations.RunPython(invert_doi_fk, elidable=True),
49+
migrations.RemoveField(
50+
model_name="collection",
51+
name="doi",
52+
),
53+
migrations.AlterField(
54+
model_name="doi",
55+
name="collection",
56+
field=models.OneToOneField(
57+
on_delete=django.db.models.deletion.PROTECT,
58+
to="core.collection",
59+
),
60+
),
61+
migrations.AlterField(
62+
model_name="doi",
63+
name="id",
64+
field=models.CharField(
65+
default=isic.core.models.doi._generate_random_doi_id, # noqa: SLF001
66+
max_length=30,
67+
primary_key=True,
68+
serialize=False,
69+
validators=[django.core.validators.RegexValidator("^\\d+\\.\\d+/\\d+$")],
70+
),
71+
),
72+
migrations.RemoveField(
73+
model_name="doi",
74+
name="url",
75+
),
76+
]

isic/core/models/collection.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from django.urls import reverse
1111
from django_extensions.db.models import TimeStampedModel
1212

13-
from .doi import Doi
1413
from .image import Image
1514

1615

@@ -83,8 +82,6 @@ class Meta(TimeStampedModel.Meta):
8382

8483
pinned = models.BooleanField(default=False)
8584

86-
doi = models.OneToOneField(Doi, on_delete=models.PROTECT, null=True, blank=True)
87-
8885
locked = models.BooleanField(default=False)
8986

9087
objects = CollectionQuerySet.as_manager()
@@ -100,15 +97,6 @@ def is_magic(self) -> bool:
10097
"""Magic collections are collections pointed to by a cohort."""
10198
return hasattr(self, "cohort")
10299

103-
@property
104-
def has_doi(self) -> bool:
105-
return self.doi is not None
106-
107-
@property
108-
def doi_url(self):
109-
if self.doi:
110-
return f"https://doi.org/{self.doi}"
111-
112100
@property
113101
def num_lesions(self):
114102
return (

isic/core/models/doi.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import json
2+
import random
23

4+
from django.conf import settings
35
from django.contrib.auth.models import User
46
from django.core.files.storage import storages
57
from django.core.validators import RegexValidator
68
from django.db import models
79
from django.urls import reverse
810
from django_extensions.db.models import TimeStampedModel
911

12+
from .collection import Collection
13+
1014

1115
def doi_upload_to(instance: "Doi", filename: str) -> str:
1216
return f"dois/{instance.id.replace('/', '-')}/{filename}"
@@ -16,19 +20,26 @@ def doi_storage():
1620
return storages["sponsored"]
1721

1822

23+
def _generate_random_doi_id():
24+
# pad DOI with leading zeros so all DOIs are prefix/6 digits
25+
return f"{settings.ISIC_DATACITE_DOI_PREFIX}/{random.randint(10_000, 999_999):06}" # noqa: S311
26+
27+
1928
class Doi(TimeStampedModel):
2029
class Meta:
2130
verbose_name = "DOI"
2231
verbose_name_plural = "DOIs"
2332

2433
id = models.CharField(
25-
max_length=30, primary_key=True, validators=[RegexValidator(r"^\d+\.\d+/\d+$")]
34+
max_length=30,
35+
primary_key=True,
36+
default=_generate_random_doi_id,
37+
validators=[RegexValidator(r"^\d+\.\d+/\d+$")],
2638
)
2739
slug = models.SlugField(max_length=150, unique=True)
40+
collection = models.OneToOneField(Collection, on_delete=models.PROTECT)
2841
creator = models.ForeignKey(User, on_delete=models.RESTRICT)
2942

30-
url = models.CharField(max_length=200)
31-
3243
bundle = models.FileField(upload_to=doi_upload_to, storage=doi_storage, null=True, blank=True)
3344
bundle_size = models.PositiveBigIntegerField(null=True, blank=True)
3445

@@ -38,10 +49,14 @@ class Meta:
3849
citations = models.JSONField(default=dict, blank=True)
3950
schema_org_dataset = models.JSONField(default=dict, blank=True)
4051

41-
def __str__(self):
52+
def __str__(self) -> str:
4253
return self.id
4354

44-
def get_absolute_url(self):
55+
@property
56+
def external_url(self) -> str:
57+
return f"https://doi.org/{self.id}"
58+
59+
def get_absolute_url(self) -> str:
4560
return reverse("core/doi-detail", kwargs={"slug": self.slug})
4661

4762
def get_schema_org_dataset_json(self):

isic/core/services/collection/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def collection_delete(*, collection: Collection, ignore_lock: bool = False) -> N
5858
if collection.studies.exists():
5959
raise ValidationError("Collections with derived studies cannot be deleted.")
6060

61-
if collection.has_doi:
61+
if hasattr(collection, "doi"):
6262
raise ValidationError("Collections with DOIs cannot be deleted.")
6363

6464
collection.delete()
@@ -131,7 +131,7 @@ def collection_merge_magic_collections(
131131
if hasattr(src_collection, "cohort") and src_collection.cohort != dest_collection.cohort:
132132
logger.info("Abandoning cohort %s", src_collection.cohort.pk)
133133

134-
for field in ["public", "pinned", "doi", "locked"]:
134+
for field in ["public", "pinned", "locked"]:
135135
dest_collection_value = getattr(dest_collection, field)
136136
collection_value = getattr(src_collection, field)
137137
if dest_collection_value != collection_value:

isic/core/services/collection/doi.py

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import logging
22
from pathlib import Path
3-
import random
43
from typing import TYPE_CHECKING, Any
54
from urllib import parse
65

@@ -19,7 +18,6 @@
1918
from isic.core.services.collection import (
2019
collection_get_creators_in_attribution_order,
2120
collection_lock,
22-
collection_update,
2321
)
2422
from isic.core.services.snapshot import snapshot_images
2523
from isic.core.tasks import (
@@ -101,17 +99,12 @@ def collection_build_draft_doi(*, doi_id: str) -> dict:
10199
}
102100

103101

104-
def collection_generate_random_doi_id():
105-
# pad DOI with leading zeros so all DOIs are prefix/6 digits
106-
return f"{settings.ISIC_DATACITE_DOI_PREFIX}/{random.randint(10_000, 999_999):06}" # noqa: S311
107-
108-
109102
def collection_check_create_doi_allowed(
110103
*, user: User, collection: Collection, supplemental_files=None
111104
) -> None:
112105
if not user.has_perm("core.create_doi", collection):
113106
raise ValidationError("You don't have permissions to do that.")
114-
if collection.doi:
107+
if hasattr(collection, "doi"):
115108
raise ValidationError("This collection already has a DOI.")
116109
if not collection.public:
117110
raise ValidationError("A collection must be public to issue a DOI.")
@@ -175,21 +168,13 @@ def collection_create_doi(*, user: User, collection: Collection, supplemental_fi
175168
user=user, collection=collection, supplemental_files=supplemental_files
176169
)
177170

178-
doi_id = collection_generate_random_doi_id()
179-
draft_doi_dict = collection_build_draft_doi(doi_id=doi_id)
180-
doi_dict = collection_build_doi(collection=collection, doi_id=doi_id)
181-
182171
with transaction.atomic():
183172
# First, create the local DOI record to validate uniqueness within our known set
184-
doi = Doi(
185-
id=doi_id, slug=slugify(collection.name), creator=user, url=f"https://doi.org/{doi_id}"
186-
)
173+
doi = Doi(slug=slugify(collection.name), collection=collection, creator=user)
187174
doi.full_clean()
188175
doi.save()
189176

190-
# Lock the collection, set the DOI on it
191177
collection_lock(collection=collection)
192-
collection_update(collection=collection, doi=doi, ignore_lock=True)
193178

194179
if supplemental_files:
195180
for supplemental_file in supplemental_files:
@@ -200,17 +185,20 @@ def collection_create_doi(*, user: User, collection: Collection, supplemental_fi
200185
size=supplemental_file["blob"].size,
201186
)
202187

188+
draft_doi_dict = collection_build_draft_doi(doi_id=doi.id)
189+
doi_dict = collection_build_doi(collection=collection, doi_id=doi.id)
190+
203191
# Reserve the DOI using the draft mechanism.
204192
# If it fails, transaction will rollback, nothing in our database will change.
205193
_datacite_create_doi(draft_doi_dict)
206194

207195
# Convert to a published DOI. If this fails, someone will have to come along later and
208196
# retry to publish it. (May want a django-admin action for this if it ever happens.)
209-
_datacite_update_doi(doi_dict, doi_id)
197+
_datacite_update_doi(doi_dict, doi.id)
210198

211-
create_doi_bundle_task.delay_on_commit(doi_id)
212-
fetch_doi_citations_task.delay_on_commit(doi_id)
213-
fetch_doi_schema_org_dataset_task.delay_on_commit(doi_id)
199+
create_doi_bundle_task.delay_on_commit(doi.id)
200+
fetch_doi_citations_task.delay_on_commit(doi.id)
201+
fetch_doi_schema_org_dataset_task.delay_on_commit(doi.id)
214202

215203
logger.info("User %d created DOI %s for collection %d", user.id, doi.id, collection.id)
216204

isic/core/templates/core/collection_detail.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
{% if collection.doi %}
3939
<li>
4040
<span class="font-bold">DOI:</span>
41-
<a href="{{ collection.doi.url }}">{{ collection.doi.url }}</a>
41+
<a href="{{ collection.doi.external_url }}">{{ collection.doi.external_url }}</a>
4242
</li>
4343
{% endif %}
4444
<li>

0 commit comments

Comments
 (0)