Repository: hydroshare/hydroshare

Issue:
Add validation for ORCID identifiers so that they match Crossref schema
**Description of the bug**
If a user editing their profile submits an ORCID identifier that is a url, it will be deemed valid even if it does not specifically match the form for an ORCID link. We should strengthen our validation around ORCID (and possibly other) identifiers:
https://github.com/hydroshare/hydroshare/blob/2.12.3/hs_core/models.py#L677-L725

This is particularly important because we include identifiers in our XML submission to Crossref. If the identifier doesn't match their schema, the DOI request will be rejected.

Currently this resource is pending because of this issue:
https://www.hydroshare.org/resource/db248589a52a41c4a6f387bbefbab3a9/

Message from Crossref deposit 1611126012
```
<?xml version="1.0" encoding="UTF-8"?>
<doi_batch_diagnostic status="completed" sp="ds5">
   <submission_id>1611126012</submission_id>
   <batch_id>db248589a52a41c4a6f387bbefbab3a9</batch_id>
   <record_diagnostic status="Failure">
      <doi />
      <msg>Error: cvc-pattern-valid: Value 'https://orcid.org/my-orcid?orcid=0000-0002-5995-9297' is not facet-valid with respect to pattern 'https?://orcid.org/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[X0-9]{1}' for type 'orcid_t'.
Error: cvc-complex-type.2.2: Element 'ORCID' must have no element [children], and the value must be valid.
</msg>
   </record_diagnostic>
   <batch_data>
      <record_count>1</record_count>
      <success_count>0</success_count>
      <warning_count>0</warning_count>
      <failure_count>1</failure_count>
   </batch_data>
</doi_batch_diagnostic>
```


**Expected behavior**
We should ensure that our identifiers are validated at entry. We should also update existing identifiers so that they are conformant.

**Additional information**
HS v 2.12.3



Discussion:
```
>>> from hs_core.models import BaseResource
>>> res = BaseResource.objects.get(short_id='db248589a52a41c4a6f387bbefbab3a9')
>>> res.metadata.creators.all()
<QuerySet [<Creator: Sharma, Abhinav>, <Creator: Castro-Bolinaga, Celso>]>
>>> u = res.metadata.creators.first()
<Creator: Sharma, Abhinav>
>>> u.identifiers
{'ORCID': 'https://orcid.org/my-orcid?orcid=0000-0002-5995-9297'}
```

Resolving patch:
diff --git a/hs_core/models.py b/hs_core/models.py
--- a/hs_core/models.py
+++ b/hs_core/models.py
@@ -494,6 +494,10 @@ def can_edit(cls, adaptor_field):
         return cm.can_change(adaptor_field.request)
 
 
+class PartyValidationError(ValidationError):
+    pass
+
+
 class Party(AbstractMetaDataElement):
     """Define party model to define a person."""
 
@@ -614,14 +618,14 @@ def create(cls, **kwargs):
 
             if ('name' not in kwargs or kwargs['name'] is None) and \
                     ('organization' not in kwargs or kwargs['organization'] is None):
-                raise ValidationError(
+                raise PartyValidationError(
                     "Either an organization or name is required for a creator element")
 
             if 'name' in kwargs and kwargs['name'] is not None:
                 if len(kwargs['name'].strip()) == 0:
                     if 'organization' in kwargs and kwargs['organization'] is not None:
                         if len(kwargs['organization'].strip()) == 0:
-                            raise ValidationError(
+                            raise PartyValidationError(
                                 "Either the name or organization must not be blank for the creator "
                                 "element")
 
@@ -645,7 +649,7 @@ def update(cls, element_id, **kwargs):
             party = cls.objects.get(id=element_id)
             if party.hydroshare_user_id is not None and kwargs['hydroshare_user_id'] is not None:
                 if party.hydroshare_user_id != kwargs['hydroshare_user_id']:
-                    raise ValidationError("HydroShare user identifier can't be changed.")
+                    raise PartyValidationError("HydroShare user identifier can't be changed.")
 
         if 'order' in kwargs and element_name == 'Creator':
             creator_order = kwargs['order']
@@ -707,7 +711,7 @@ def remove(cls, element_id):
         if isinstance(party, Creator):
             if Creator.objects.filter(object_id=party.object_id,
                                       content_type__pk=party.content_type.id).count() == 1:
-                raise ValidationError("The only creator of the resource can't be deleted.")
+                raise PartyValidationError("The only creator of the resource can't be deleted.")
 
             creators_to_update = Creator.objects.filter(
                 object_id=party.object_id,
@@ -733,33 +737,33 @@ def validate_identifiers(cls, identifiers):
                 try:
                     identifiers = json.loads(identifiers)
                 except ValueError:
-                    raise ValidationError("Value for identifiers not in the correct format")
+                    raise PartyValidationError("Value for identifiers not in the correct format")
         # identifiers = kwargs['identifiers']
         if identifiers:
             # validate the identifiers are one of the supported ones
             for name in identifiers:
                 if name not in cls.supported_identifiers:
-                    raise ValidationError("Invalid data found for identifiers. "
-                                          "{} not a supported identifier.". format(name))
+                    raise PartyValidationError("Invalid data found for identifiers. "
+                                               "{} not a supported identifier.". format(name))
             # validate identifier values - check for duplicate links
             links = [link.lower() for link in list(identifiers.values())]
             if len(links) != len(set(links)):
-                raise ValidationError("Invalid data found for identifiers. "
-                                      "Duplicate identifier links found.")
+                raise PartyValidationError("Invalid data found for identifiers. "
+                                           "Duplicate identifier links found.")
 
             for link in links:
                 validator = URLValidator()
                 try:
                     validator(link)
                 except ValidationError:
-                    raise ValidationError("Invalid data found for identifiers. "
-                                          "Identifier link must be a URL.")
+                    raise PartyValidationError("Invalid data found for identifiers. "
+                                               "Identifier link must be a URL.")
 
             # validate identifier keys - check for duplicate names
             names = [n.lower() for n in list(identifiers.keys())]
             if len(names) != len(set(names)):
-                raise ValidationError("Invalid data found for identifiers. "
-                                      "Duplicate identifier names found")
+                raise PartyValidationError("Invalid data found for identifiers. "
+                                           "Duplicate identifier names found")
 
             # validate that the links for the known identifiers are valid
             for id_name in cls.supported_identifiers:
@@ -767,7 +771,8 @@ def validate_identifiers(cls, identifiers):
                 if id_link:
                     regex = cls.supported_identifiers[id_name]
                     if not re.match(regex, id_link):
-                        raise ValidationError(f"URL for {id_name} is invalid.")
+                        raise PartyValidationError("Invalid data found for identifiers. "
+                                                   f"\'{id_link}\' is not a valid {id_name}.")
         return identifiers
 
 
diff --git a/hs_core/views/__init__.py b/hs_core/views/__init__.py
--- a/hs_core/views/__init__.py
+++ b/hs_core/views/__init__.py
@@ -62,6 +62,7 @@
     Subject,
     TaskNotification,
     resource_processor,
+    PartyValidationError,
 )
 from hs_core.task_utils import (
     dismiss_task_by_id,
@@ -1768,6 +1769,11 @@ def create_resource(request, *args, **kwargs):
             full_paths=full_paths,
             auto_aggregate=auto_aggregate,
         )
+    except PartyValidationError as ex:
+        party_message = "Validation issue in Creator or Contributor metadata. " + \
+            f"One of the profiles contains invalid identifiers: {str(ex)}."
+        ajax_response_data["message"] = party_message
+        return JsonResponse(ajax_response_data)
     except SessionException as ex:
         ajax_response_data["message"] = ex.stderr
         return JsonResponse(ajax_response_data)


Test patch: