Coverage for datacite/models.py: 99%

328 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2025-07-29 15:38 +0000

1import logging 

2from collections.abc import Mapping 

3from typing import Any 

4 

5import pycountry 

6from django.conf import settings 

7from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation 

8from django.contrib.contenttypes.models import ContentType 

9from django.core.validators import MaxValueValidator, MinValueValidator 

10from django.db import IntegrityError, models 

11from django.urls import reverse 

12 

13from datacite.validators import YearValidators, validate_uri 

14from network.models import Network 

15 

16LANGUAGES = [ 

17 (lang.alpha_2, lang.name) 

18 for lang in pycountry.languages 

19 if hasattr(lang, "alpha_2") 

20] 

21LANGUAGES.insert(0, ("", "--")) 

22DEFAULT_LANGUAGE = pycountry.languages.get(name="English").alpha_2 

23 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28class IdentifierScheme(models.Model): 

29 scheme = models.CharField( 

30 max_length=255, unique=True, help_text="The identifier scheme." 

31 ) 

32 uri = models.CharField( 

33 max_length=255, 

34 null=True, 

35 blank=True, 

36 unique=True, 

37 help_text="URI of the identifier scheme.", 

38 ) 

39 

40 def __str__(self) -> str: 

41 return f"{self.scheme} ({self.uri})" 

42 

43 

44class IdentifierQuerySet(models.QuerySet["Identifier"]): 

45 def get_or_create( 

46 self, defaults: Mapping[str, Any] | None = None, **kwargs: Any 

47 ) -> tuple[Any, bool]: 

48 """Takes the information of an identifier, gets it if existing, otherwise 

49 creates it. 

50 (identifier:str, scheme:dict[str,str], instance:Model|None) 

51 -> (Identifier, was_created:bool)""" 

52 if defaults is None: 

53 defaults = {} 

54 

55 if "identifier" in kwargs and "scheme" in kwargs: 

56 return self.get_or_create_specific( 

57 identifier=kwargs.get("identifier", ""), 

58 scheme=kwargs["scheme"].get("scheme"), 

59 uri=kwargs["scheme"].get("uri", ""), 

60 identified_instance=kwargs.get("instance"), 

61 defaults=defaults, # type: ignore[arg-type] 

62 ) 

63 return super().get_or_create(defaults=defaults, **kwargs) 

64 

65 def get_or_create_specific( 

66 self, 

67 identifier: str, 

68 scheme: str, 

69 uri: str, 

70 defaults: dict[str, Any], 

71 identified_instance: Any | None = None, 

72 ) -> tuple[Any, bool]: 

73 # Try to get the identifier 

74 try: 

75 return self.get(identifier__iexact=identifier), False 

76 except Identifier.DoesNotExist: 

77 pass 

78 

79 # Try to get the scheme 

80 try: 

81 defaults["scheme"], _ = IdentifierScheme.objects.get_or_create( 

82 scheme=scheme, defaults={"uri": uri} 

83 ) 

84 except IntegrityError: 

85 # uri exists, but not the scheme. get the corresponding to the uri 

86 defaults["scheme"] = IdentifierScheme.objects.get(uri=uri) 

87 

88 # Prepare identified instance 

89 if identified_instance: 

90 defaults["content_type"] = ContentType.objects.get_for_model( 

91 identified_instance 

92 ) 

93 defaults["object_id"] = identified_instance.pk 

94 

95 return super().get_or_create(identifier=identifier, defaults=defaults) 

96 

97 

98class Identifier(models.Model): 

99 identifier = models.CharField( 

100 max_length=255, 

101 unique=True, 

102 help_text="The identifier itself (full URI)", 

103 ) 

104 scheme = models.ForeignKey( 

105 IdentifierScheme, on_delete=models.PROTECT, help_text="The identifier scheme." 

106 ) 

107 

108 content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 

109 object_id = models.PositiveIntegerField() 

110 content_object = GenericForeignKey("content_type", "object_id") 

111 

112 objects = IdentifierQuerySet.as_manager() 

113 

114 class Meta: 

115 indexes = [ 

116 models.Index(fields=["content_type", "object_id"]), 

117 ] 

118 

119 def __str__(self) -> str: 

120 return self.identifier 

121 

122 

123class ResourceTypes(models.TextChoices): 

124 DEFAULT = "", "" 

125 AUDIOVISUAL = "Audiovisual", "Audiovisual" 

126 AWARD = "Award", "Award" 

127 DATASET = "Dataset", "Dataset" 

128 OTHER = "Other", "Other" 

129 

130 

131class ResourceType(models.Model): 

132 resource_type_general = models.CharField( 

133 max_length=31, 

134 choices=ResourceTypes, 

135 help_text="Type of resource as defined " 

136 "by <a href='https://datacite-metadata-schema.readthedocs.io/en/4.6/properties/resourcetype/#a-resourcetypegeneral'>Datacite</a>.", 

137 ) 

138 resource_type = models.CharField( 

139 max_length=255, help_text="Single term for subtype." 

140 ) 

141 

142 def __str__(self) -> str: 

143 return f"{self.resource_type}" 

144 

145 

146class Publisher(models.Model): 

147 """https://datacite-metadata-schema.readthedocs.io/en/4/properties/publisher/""" 

148 

149 name = models.CharField(max_length=255, help_text="Official name of the publisher.") 

150 lang = models.CharField( 

151 max_length=60, 

152 choices=LANGUAGES, 

153 blank=True, 

154 help_text="Language of the publisher.", 

155 ) 

156 identifier = GenericRelation( 

157 Identifier, 

158 related_query_name="publisher", 

159 help_text="Identifier of the publisher.", 

160 ) 

161 

162 def __str__(self) -> str: 

163 return f"{self.name}" 

164 

165 

166class ParticipantTypes(models.TextChoices): 

167 DEFAULT = "", "" 

168 PERSONAL = "Personal", "Personal" 

169 ORGANIZATION = "Organizational", "Organizational" 

170 

171 

172class Participant(models.Model): 

173 name = models.CharField( 

174 max_length=255, 

175 help_text="Name of the person or organization. " 

176 "This is the name that will appear in the citation.", 

177 ) 

178 given_name = models.CharField( 

179 max_length=255, 

180 blank=True, 

181 default="", 

182 help_text="Given name of the participant.", 

183 ) 

184 family_name = models.CharField( 

185 max_length=255, 

186 blank=True, 

187 default="", 

188 help_text="Family name of the participant.", 

189 ) 

190 name_type = models.CharField( 

191 max_length=15, 

192 choices=ParticipantTypes, 

193 default=ParticipantTypes.DEFAULT, 

194 blank=True, 

195 help_text="Type of name as defined by <a href='https://datacite-metadata-schema.readthedocs.io/en/4.6/properties/creator/#a-nametype'>Datacite</a>.", 

196 ) 

197 lang = models.CharField( 

198 max_length=60, 

199 choices=LANGUAGES, 

200 default="", 

201 blank=True, 

202 help_text="Langue of the organization.", 

203 ) 

204 identifiers = GenericRelation( 

205 Identifier, 

206 related_query_name="creator", 

207 help_text="Identifier of the person/organization.", 

208 ) 

209 affiliations = models.ManyToManyField( 

210 "self", 

211 blank=True, 

212 related_name="affiliation_set", 

213 help_text="Affiliation of the person/organization. If it does not exist, " 

214 "please create a 'Creator' for this affiliation. ", 

215 symmetrical=False, 

216 limit_choices_to={"name_type": ParticipantTypes.ORGANIZATION}, 

217 ) 

218 is_funder = models.BooleanField( 

219 default=False, 

220 help_text="Whether the participant is a funder. Only organizations.", 

221 ) 

222 

223 class Meta: 

224 constraints = [ 

225 models.CheckConstraint( 

226 condition=( 

227 ( 

228 models.Q(name_type=ParticipantTypes.PERSONAL) 

229 & models.Q(is_funder=False) 

230 ) 

231 | models.Q(name_type=ParticipantTypes.ORGANIZATION) 

232 | models.Q(name_type=ParticipantTypes.DEFAULT) 

233 ), 

234 name="person_is_not_funder", 

235 ), 

236 ] 

237 

238 def __str__(self) -> str: 

239 return f"{self.name}" 

240 

241 

242class MetadataState(models.TextChoices): 

243 DRAFT = "draft", "Draft" 

244 REGISTERED = "registered", "Registered" 

245 FINDABLE = "findable", "Findable" 

246 

247 

248class MetadataCreator(models.Model): 

249 metadata = models.ForeignKey("Metadata", on_delete=models.CASCADE) 

250 creator = models.ForeignKey(Participant, on_delete=models.PROTECT) 

251 order = models.IntegerField( 

252 null=True, default=-1, help_text="Order of the Creators." 

253 ) 

254 

255 class Meta: 

256 constraints = [ 

257 models.UniqueConstraint( 

258 fields=("metadata", "creator"), name="creator_once_per_metadata2" 

259 ), 

260 ] 

261 

262 def __str__(self) -> str: 

263 return f"{self.metadata}-{self.creator}" 

264 

265 

266class ContributorTypes(models.TextChoices): 

267 """ 

268 https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/contributorType/ 

269 """ 

270 

271 DEFAULT = "", "" 

272 CONTACT_PERSON = "ContactPerson", "Contact person" 

273 DATA_COLLECTOR = "DataCollector", "Data collector" 

274 DATA_CURATOR = "DataCurator", "Data curator" 

275 DATA_MANAGER = "DataManager", "Data manager" 

276 DISTRIBUTOR = "Distributor", "Distributor" 

277 HOSTING_INSTITUTION = "HostingInstitution", "Hosting institution" 

278 SPONSOR = "Sponsor", "Sponsor" 

279 # Present in datacite.prod but not in recommendations. 

280 PROJECT_LEADER = "ProjectLeader", "Project leader" 

281 PROJECT_MEMBER = "ProjectMember", "Project member" 

282 FUNDER = "Funder", "Funder" 

283 OTHER = "Other", "Other" 

284 

285 

286class MetadataContributor(models.Model): 

287 metadata = models.ForeignKey("Metadata", on_delete=models.CASCADE) 

288 contributor = models.ForeignKey(Participant, on_delete=models.PROTECT) 

289 contributor_type = models.CharField( 

290 max_length=31, choices=ContributorTypes, help_text="Role of the participant." 

291 ) 

292 

293 def __str__(self) -> str: 

294 return f"{self.metadata}-{self.contributor} ({self.contributor_type})" 

295 

296 

297EMBARGOED = "embargoed" 

298 

299 

300class Rights(models.Model): 

301 """https://datacite-metadata-schema.readthedocs.io/en/4/properties/rights/""" 

302 

303 rights = models.CharField(max_length=255) 

304 rights_uri = models.URLField(validators=[validate_uri]) 

305 identifier = GenericRelation( 

306 Identifier, related_query_name="rights", help_text="Identifier of the rights." 

307 ) 

308 lang = models.CharField( 

309 max_length=60, 

310 choices=LANGUAGES, 

311 default="", 

312 blank=True, 

313 ) 

314 

315 def __str__(self) -> str: 

316 return f"{self.rights}" 

317 

318 

319class DescriptionTypes(models.TextChoices): 

320 """https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/descriptionType/""" 

321 

322 DEFAULT = "", "" 

323 ABSTRACT = "Abstract", "Abstract" 

324 METHODS = "Methods", "Methods" 

325 SERIES_INFO = "SeriesInformation", "Series information" 

326 TABLE_OF_CONTENTS = "TableOfContents", "Table of contents" 

327 TECHNICAL_INFO = "TechnicalInfo", "Technical information" 

328 OTHER = "Other", "Other" 

329 

330 

331class Description(models.Model): 

332 """https://datacite-metadata-schema.readthedocs.io/en/4.6/properties/description/""" 

333 

334 description = models.TextField() 

335 description_type = models.CharField(max_length=31, choices=DescriptionTypes) 

336 lang = models.CharField( 

337 max_length=60, 

338 choices=LANGUAGES, 

339 default="", 

340 blank=True, 

341 ) 

342 

343 metadata = models.ForeignKey( 

344 "Metadata", 

345 null=True, 

346 on_delete=models.CASCADE, 

347 help_text="A short summary of the network, no more than 200-300 words. " 

348 "This should include the number, location, and types of sensors, and the " 

349 "type of data collected. Permanent networks frequently gain or lose " 

350 "stations, so there is little sense in being too specific concerning " 

351 "these. Suggested components : Description, aim and scope, Geodynamic " 

352 "setting (for temporary networks), Geographical coverage, Instrument " 

353 "types, (number of stations for temporary networks), Data policy " 

354 "(e.g. fully open/restricted/embargoed), Any peculiarities of the " 

355 "network.", 

356 ) 

357 

358 def __str__(self) -> str: 

359 return f"{str(self.description)[:20]}..." 

360 

361 

362class Format(models.Model): 

363 """https://datacite-metadata-schema.readthedocs.io/en/4.6/properties/format/""" 

364 

365 format = models.CharField( 

366 max_length=255, 

367 help_text=( 

368 "Technical format of the resource. Use file extension or MIME type where " 

369 "possible, e.g., PDF, XML, MPG or application/pdf, text/xml, video/mpeg." 

370 ), 

371 ) 

372 

373 def __str__(self) -> str: 

374 return f"{self.format}" 

375 

376 

377class Funding(models.Model): 

378 """https://datacite-metadata-schema.readthedocs.io/en/4.6/properties/fundingreference/""" 

379 

380 funder = models.ForeignKey( 

381 Participant, on_delete=models.PROTECT, limit_choices_to={"is_funder": True} 

382 ) 

383 award_number = models.CharField(max_length=255, default="", blank=True) 

384 award_uri = models.URLField(max_length=255, default="", blank=True) 

385 award_title = models.CharField(max_length=255, default="", blank=True) 

386 

387 def __str__(self) -> str: 

388 if self.award_title: 

389 name = self.award_title 

390 elif self.award_number: 

391 name = self.award_number 

392 elif self.award_uri: 

393 name = self.award_uri 

394 else: 

395 name = "-" 

396 return f"{name} ({self.funder})" 

397 

398 

399class MetadataManager(models.Manager): 

400 def create_from_network(self, network: Network) -> None: 

401 metadata = self.create( 

402 network=network, 

403 url=settings.LANDING_PAGE_BASE_URL + str(network), 

404 publication_year=network.start_year, 

405 types=ResourceType.objects.get( 

406 resource_type=settings.DEFAULT_RESOURCE_TYPE, 

407 resource_type_general=ResourceTypes.DATASET, 

408 ), 

409 publisher=Publisher.objects.get( 

410 identifier__identifier=settings.DEFAULT_PUBLISHER_ID 

411 ), 

412 ) 

413 metadata.title_set.create(title=network.name, title_type=TitleTypes.MAIN_TITLE) # type: ignore[attr-defined] 

414 metadata.add_ordered_creator( # type: ignore[attr-defined] 

415 Participant.objects.get(name=settings.DEFAULT_CREATOR_NAME), 0 

416 ) 

417 cc_by_40 = Rights.objects.get( 

418 identifier__identifier=settings.DEFAULT_FREE_LICENSE 

419 ) 

420 metadata.rights.add(cc_by_40) # type: ignore[attr-defined] 

421 

422 

423class Metadata(models.Model): 

424 url = models.URLField(help_text="Landing page of the resource.") 

425 publication_year = models.IntegerField( 

426 validators=[*YearValidators], help_text="Year of publication." 

427 ) 

428 language = models.CharField( 

429 max_length=60, 

430 choices=LANGUAGES, 

431 default="", 

432 blank=True, 

433 help_text="Language of the resource.", 

434 ) 

435 state = models.CharField( 

436 max_length=15, choices=MetadataState, blank=True, default=MetadataState.DRAFT 

437 ) 

438 creators = models.ManyToManyField( 

439 Participant, 

440 through=MetadataCreator, 

441 help_text="List of creators of this resource.", 

442 related_name="creations", 

443 ) 

444 contributors = models.ManyToManyField( 

445 Participant, 

446 through=MetadataContributor, 

447 help_text="List of contributors of this resource.", 

448 related_name="contributions", 

449 ) 

450 

451 types = models.ForeignKey( 

452 ResourceType, on_delete=models.PROTECT, help_text="Type of resource." 

453 ) 

454 publisher = models.ForeignKey( 

455 Publisher, on_delete=models.PROTECT, help_text="Publisher of the metadata" 

456 ) 

457 network = models.OneToOneField( 

458 Network, 

459 on_delete=models.CASCADE, 

460 help_text="Network to which this metadata corresponds.", 

461 ) 

462 formats = models.ManyToManyField( 

463 Format, 

464 help_text=( 

465 "Technical format of the resource. Use file extension or MIME type where " 

466 "possible, e.g., PDF, XML, MPG or application/pdf, text/xml, video/mpeg." 

467 ), 

468 related_name="metadata_set", 

469 blank=True, 

470 ) 

471 rights = models.ManyToManyField( 

472 Rights, 

473 help_text="Rights information for this dataset.", 

474 related_name="metadata_set", 

475 blank=True, 

476 ) 

477 collected_start = models.DateField(null=True, help_text="Date of the first data.") 

478 collected_end = models.DateField( 

479 null=True, blank=True, help_text="Date of the last data." 

480 ) 

481 issued = models.DateField( 

482 null=True, 

483 help_text=( 

484 "Start date of the network. It's this date that will appear on citations." 

485 ), 

486 ) 

487 available = models.DateField( 

488 null=True, 

489 blank=True, 

490 help_text=( 

491 "If needed, the date of the end of the embargo. " 

492 "Do not fill if there is no embargo." 

493 ), 

494 ) 

495 size_information = models.CharField( 

496 max_length=255, 

497 blank=True, 

498 default="", 

499 help_text="Approximate of the number of active stations.", 

500 ) 

501 size_increment = models.CharField( 

502 max_length=255, 

503 default="", 

504 blank=True, 

505 help_text="Day increment in 'GB/year' (only for Permanent Networks).", 

506 ) 

507 size_total = models.CharField( 

508 default="", 

509 blank=True, 

510 max_length=255, 

511 help_text=( 

512 "Total size in TB for a Temporary Network. " 

513 "Total size in a given year for a Permanent Network." 

514 ), 

515 ) 

516 fundings = models.ManyToManyField( 

517 Funding, 

518 help_text=( 

519 "Information about financial support (funding) for the resource being " 

520 "registered." 

521 ), 

522 related_name="metadata_set", 

523 blank=True, 

524 ) 

525 

526 objects = MetadataManager() 

527 

528 def __str__(self) -> str: 

529 return f"{self.network.doi}" 

530 

531 def get_absolute_url(self) -> str: 

532 return reverse("datacite:metadata-detail", kwargs={"pk": self.network.pk}) 

533 

534 def add_ordered_creator(self, participant: Participant, order: int) -> None: 

535 MetadataCreator.objects.update_or_create( 

536 creator=participant, metadata=self, defaults={"order": order} 

537 ) 

538 

539 def add_ordered_creators(self, participants: list[Participant]) -> None: 

540 MetadataCreator.objects.filter(metadata=self).exclude( 

541 creator__in=participants 

542 ).delete() 

543 creators_of_metadata = [ 

544 MetadataCreator(creator=participant, order=order, metadata=self) 

545 for order, participant in enumerate(participants) 

546 ] 

547 MetadataCreator.objects.bulk_create( 

548 creators_of_metadata, 

549 update_conflicts=True, 

550 update_fields=["order"], 

551 unique_fields=["creator", "metadata"], 

552 ) 

553 

554 def add_ordered_creators_from_ids(self, participant_ids: list[int | str]) -> None: 

555 participants = [] 

556 for participant_id in participant_ids: 

557 if isinstance(participant_id, str) and participant_id.isdigit(): 

558 index = int(participant_id) 

559 elif isinstance(participant_id, int): 

560 index = participant_id 

561 else: 

562 continue 

563 

564 try: 

565 participant = Participant.objects.get(pk=index) 

566 except Participant.DoesNotExist: 

567 continue 

568 

569 participants.append(participant) 

570 

571 self.add_ordered_creators(participants) 

572 

573 

574class TitleTypes(models.TextChoices): 

575 DEFAULT = "", "" 

576 MAIN_TITLE = "MainTitle", "Main title" 

577 ALTERNATIVELY_TITLE = "AlternativeTitle", "Alternative title" 

578 SUBTITLE = "Subtitle", "Subtitle" 

579 TRANSLATED_TITLE = "TranslatedTitle", "Translated title" 

580 OTHER = "Other", "Other" 

581 

582 

583class Title(models.Model): 

584 """https://datacite-metadata-schema.readthedocs.io/en/4/properties/title/""" 

585 

586 title = models.CharField(max_length=255, help_text="Title of the resource.") 

587 title_type = models.CharField( 

588 max_length=31, 

589 choices=TitleTypes, 

590 default=TitleTypes.DEFAULT, 

591 blank=True, 

592 help_text="Type of title as defined by " 

593 "<a href='https://datacite-metadata-schema.readthedocs.io/en/4.6/properties" 

594 "/title/#a-titletype'>Datacite</a>.", 

595 ) 

596 lang = models.CharField( 

597 max_length=60, 

598 choices=LANGUAGES, 

599 default="", 

600 blank=True, 

601 help_text="Langue of the title.", 

602 ) 

603 metadata = models.ForeignKey( 

604 Metadata, 

605 on_delete=models.CASCADE, 

606 help_text="Metadata associated to the title.", 

607 ) 

608 

609 def __str__(self) -> str: 

610 return f"{self.title}" 

611 

612 

613class Subject(models.Model): 

614 """https://datacite-metadata-schema.readthedocs.io/en/4/properties/subject/""" 

615 

616 subject = models.CharField(max_length=255) 

617 lang = models.CharField( 

618 max_length=60, 

619 choices=LANGUAGES, 

620 default="", 

621 blank=True, 

622 ) 

623 value_uri = models.URLField() 

624 subject_scheme = models.CharField(max_length=255) # maybe auto from value_uri 

625 scheme_uri = models.URLField() # maybe auto from value_uri 

626 

627 def __str__(self) -> str: 

628 return f"{self.subject}" 

629 

630 

631class RelatedIdentifierTypes(models.TextChoices): 

632 """https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/relatedIdentifierType/""" 

633 

634 DEFAULT = "", "" 

635 ARK = "ARK", "ARK" 

636 ARXIV = "ArXiv", "ArXiv" 

637 BIBCODE = "bibcode", "bibcode" 

638 CSTR = "CSTR", "CSTR" 

639 DOI = "DOI", "DOI" 

640 EAN13 = "EAN13", "EAN13" 

641 EISSN = "EISSN", "EISSN" 

642 HANDLE = "Handle", "Handle" 

643 IGSN = "IGSN", "IGSN" 

644 ISBN = "ISBN", "ISBN" 

645 ISSN = "ISSN", "ISSN" 

646 ISTC = "ISTC", "ISTC" 

647 LISSN = "LISSN", "LISSN" 

648 LSID = "LSID", "LSID" 

649 PMID = "PMID", "PMID" 

650 PURL = "PURL", "PURL" 

651 RRID = "RRID", "RRID" 

652 UPC = "UPC", "UPC" 

653 URL = "URL", "URL" 

654 URN = "URN", "URN" 

655 W3ID = "W3ID", "W3ID" 

656 

657 

658class RelationTypes(models.TextChoices): 

659 """https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/relationType/""" 

660 

661 DEFAULT = "", "" 

662 IS_CITED_BY = "IsCitedBy", "Is cited by" 

663 CITES = "Cites", "Cites" 

664 IS_SUPPLEMENT_TO = "IsSupplementTo", "Is supplement to" 

665 IS_SUPPLEMENTED_BY = "IsSupplementedBy", "Is supplemented by" 

666 IS_CONTINUED_BY = "IsContinuedBy", "Is continued by" 

667 CONTINUES = "Continues", "Continues" 

668 IS_DESCRIBED_BY = "IsDescribedBy", "Is described by" 

669 DESCRIBES = "Describes", "Describes" 

670 HAS_METADATA = "HasMetadata", "Has metadata" 

671 IS_METADATA_FOR = "IsMetadataFor", "Is metadata for" 

672 HAS_VERSION = "HasVersion", "Has version" 

673 IS_VERSION_OF = "IsVersionOf", "Is version of" 

674 IS_NEW_VERSION_OF = "IsNewVersionOf", "Is new version of" 

675 IS_PREVIOUS_VERSION_OF = "IsPreviousVersionOf", "Is previous version of" 

676 IS_PART_OF = "IsPartOf", "Is part of" 

677 HAS_PART = "HasPart", "Has part" 

678 IS_PUBLISHED_IN = "IsPublishedIn", "Is published in" 

679 IS_REFERENCED_IN = "IsReferencedBy", "Is referenced by" 

680 REFERENCES = "References", "References" 

681 IS_DOCUMENTED_BY = "IsDocumentedBy", "Is documented by" 

682 DOCUMENTS = "Documents", "Documents" 

683 IS_COMPILED_BY = "IsCompiledBy", "Is compiled by" 

684 COMPILES = "Compiles", "Compiles" 

685 IS_VARIANT_OF = "IsVariantFormOf", "Is variant form of" 

686 IS_ORIGINAL_FORM_OF = "IsOriginalFormOf", "Is original form of" 

687 IS_IDENTICAL_TO = "IsIdenticalTo", "Is identical to" 

688 IS_REVIEWED_BY = "IsReviewedBy", "Is reviewed by" 

689 REVIEWS = "Reviews", "Reviews" 

690 IS_DERIVED_FROM = "IsDerivedFrom", "Is derived from" 

691 IS_SOURCE_OF = "IsSourceOf", "Is source of" 

692 IS_REQUIRED_BY = "IsRequiredBy", "Is required by" 

693 REQUIRES = "Requires", "Requires" 

694 IS_OBSOLETED_BY = "IsObsoletedBy", "Is obsoleted by" 

695 OBSOLETES = "Obsoletes", "Obsoletes" 

696 IS_COLLECTED_BY = "IsCollectedBy", "Is collected by" 

697 COLLECTS = "Collects", "Collects" 

698 IS_TRANSLATION_OF = "IsTranslationOf", "Is translation of" 

699 HAS_TRANSLATION = "HasTranslation", "Has translation" 

700 

701 

702class RelatedIdentifier(models.Model): 

703 """https://datacite-metadata-schema.readthedocs.io/en/4/properties/relatedidentifier/""" 

704 

705 identifier = GenericRelation( 

706 Identifier, 

707 related_query_name="related_identifier", 

708 help_text="Identifier of the related_identifier.", 

709 ) 

710 relation_type = models.CharField(max_length=31, choices=RelationTypes) 

711 resource_type_general = models.CharField(max_length=31, choices=ResourceTypes) 

712 related_metadata_scheme = models.CharField(max_length=255, default="", blank=True) 

713 scheme_uri = models.URLField(default="", blank=True) 

714 scheme_type = models.CharField(max_length=255, default="", blank=True) 

715 

716 def __str__(self) -> str: 

717 return f"{self.identifier.first().identifier}" 

718 

719 

720class Geolocation(models.Model): 

721 """https://datacite-metadata-schema.readthedocs.io/en/4.6/properties/geolocation/""" 

722 

723 place = models.CharField( 

724 max_length=255, default="", help_text="Name of the location." 

725 ) 

726 west_bound_longitude = models.FloatField( 

727 null=True, 

728 validators=[MaxValueValidator(180), MinValueValidator(-180)], 

729 help_text="West bound of the region.", 

730 ) 

731 east_bound_longitude = models.FloatField( 

732 null=True, 

733 validators=[MaxValueValidator(180), MinValueValidator(-180)], 

734 help_text="East bound of the region.", 

735 ) 

736 south_bound_latitude = models.FloatField( 

737 null=True, 

738 validators=[MaxValueValidator(90), MinValueValidator(-90)], 

739 help_text="South bound of the region.", 

740 ) 

741 north_bound_latitude = models.FloatField( 

742 null=True, 

743 validators=[MaxValueValidator(90), MinValueValidator(-90)], 

744 help_text="North bound of the region.", 

745 ) 

746 metadata = models.ForeignKey( 

747 Metadata, on_delete=models.CASCADE, help_text="Network metadata associated." 

748 ) 

749 

750 class Meta: 

751 constraints = [ 

752 models.UniqueConstraint( 

753 fields=["metadata", "place"], name="unique_place_per_network" 

754 ), 

755 models.CheckConstraint( 

756 condition=( 

757 models.Q(west_bound_longitude__isnull=False) 

758 & models.Q(east_bound_longitude__isnull=False) 

759 & models.Q(north_bound_latitude__isnull=False) 

760 & models.Q(south_bound_latitude__isnull=False) 

761 ) 

762 | ( 

763 models.Q(west_bound_longitude__isnull=True) 

764 & models.Q(east_bound_longitude__isnull=True) 

765 & models.Q(north_bound_latitude__isnull=True) 

766 & models.Q(south_bound_latitude__isnull=True) 

767 ), 

768 name="all_coords_or_none", 

769 ), 

770 ] 

771 

772 def __str__(self) -> str: 

773 coordinates = ( 

774 f"[{self.latitude_2_str(self.south_bound_latitude)}-" 

775 f"{self.latitude_2_str(self.north_bound_latitude)}, " 

776 f"{self.longitude_2_str(self.west_bound_longitude)}-" 

777 f"{self.longitude_2_str(self.east_bound_longitude)}]" 

778 ) 

779 if self.place: 

780 return f"{self.place} {coordinates}" 

781 return coordinates 

782 

783 @staticmethod 

784 def latitude_2_str(latitude: float | None) -> str: 

785 if latitude is None: 

786 return "?" 

787 return f"{abs(latitude)}{'N' if latitude >= 0 else 'S'}" 

788 

789 @staticmethod 

790 def longitude_2_str(longitude: float | None) -> str: 

791 if longitude is None: 

792 return "?" 

793 return f"{abs(longitude)}{'E' if longitude >= 0 else 'W'}"