diff --git a/orcid_hub/__init__.py b/orcid_hub/__init__.py index d2a5b6d5b..fadc61f25 100644 --- a/orcid_hub/__init__.py +++ b/orcid_hub/__init__.py @@ -135,6 +135,7 @@ def default(self, o): return super().default(o) +app.config["JSON_AS_ASCII"] = False app.json_encoder = JSONEncoder diff --git a/orcid_hub/apis.py b/orcid_hub/apis.py index 15d199d9a..5ff54928b 100644 --- a/orcid_hub/apis.py +++ b/orcid_hub/apis.py @@ -19,7 +19,7 @@ from flask_swagger import swagger from rq import get_current_job -from . import api, app, models, oauth, rq, schemas +from . import api, app, models, oauth, rq, schemas, utils from .login_provider import roles_required from .models import (ORCID_ID_REGEX, AffiliationRecord, AsyncOrcidResponse, Client, FundingRecord, OrcidToken, PeerReviewRecord, PropertyRecord, ResourceRecord, Role, Task, @@ -2559,7 +2559,6 @@ class ResourceListAPI(TaskResource): def load_from_json(self, task=None): """Load records form the JSON upload.""" - # breakpoint() return ResourceRecord.load( request.data.decode("utf-8"), filename=self.filename, task=task) @@ -3450,15 +3449,18 @@ def exeute_orcid_call_async(method, url, data, headers): ar.save() -@app.route("/api/v1//webhook", methods=["PUT", "DELETE"]) @app.route("/api/v1//webhook/", methods=["PUT", "DELETE"]) +@app.route("/api/v1//webhook", methods=["PUT", "DELETE"]) +@app.route("/api/v1/webhook/", methods=["PUT", "DELETE"]) +@app.route("/api/v1/webhook", methods=["PUT", "POST", "PATCH"]) @oauth.require_oauth() -def register_webhook(orcid, callback_url=None): +def register_webhook(orcid=None, callback_url=None): """Handle webhook registration for an individual user with direct client call-back.""" - try: - validate_orcid_id(orcid) - except Exception as ex: - return jsonify({"error": "Missing or invalid ORCID iD.", "message": str(ex)}), 415 + if orcid: + try: + validate_orcid_id(orcid) + except Exception as ex: + return jsonify({"error": "Missing or invalid ORCID iD.", "message": str(ex)}), 415 if callback_url == "undefined": callback_url = None if callback_url: @@ -3469,17 +3471,62 @@ def register_webhook(orcid, callback_url=None): "message": f"Invalid call-back URL: {callback_url}" }), 415 - try: - user = User.get(orcid=orcid) - except User.DoesNotExist: - return jsonify({ - "error": "Invalid ORCID iD.", - "message": f"User with given ORCID ID '{orcid}' doesn't exist." - }), 404 + if orcid: + try: + user = User.get(orcid=orcid) + except User.DoesNotExist: + return jsonify({ + "error": "Invalid ORCID iD.", + "message": f"User with given ORCID ID '{orcid}' doesn't exist." + }), 404 - orcid_resp = register_orcid_webhook(user, callback_url, delete=request.method == "DELETE") - resp = make_response('', orcid_resp.status_code if orcid_resp else 204) - if orcid_resp and "Location" in orcid_resp.headers: - resp.headers["Location"] = orcid_resp.headers["Location"] + orcid_resp = register_orcid_webhook(user, callback_url, delete=request.method == "DELETE") + resp = make_response('', orcid_resp.status_code if orcid_resp else 204) + if orcid_resp and "Location" in orcid_resp.headers: + resp.headers["Location"] = orcid_resp.headers["Location"] + return resp - return resp + else: + org = current_user.organisation + was_enabled = org.webhook_enabled + + if request.method == "DELETE": + if org.webhook_enabled: + job = utils.disable_org_webhook.queue(org) + return jsonify({"job-id": job.id}), 200 + return '', 204 + else: + data = request.json or {} + enabled = data.get("enabled") + url = data.get("url", callback_url) + append_orcid = data.get("append-orcid") + apikey = data.get("apikey") + email_notifications_enabled = data.get("email-notifications-enabled") + notification_email = data.get("notification-email") + + if url is not None: + org.webhook_url = url + if append_orcid is not None: + org.webhook_append_orcid = bool(append_orcid) + if apikey is not None: + org.webhook_apikey = apikey + if email_notifications_enabled is not None: + org.email_notifications_enabled = bool(email_notifications_enabled) + if notification_email is not None: + org.notification_email = notification_email + org.save() + + data = {k: v for (k, v) in [ + ("enabled", org.webhook_enabled), + ("url", org.webhook_url), + ("append-orcid", org.webhook_append_orcid), + ("apikey", org.webhook_apikey), + ("email-notifications-enabled", org.email_notifications_enabled), + ("notification-email", org.notification_email), + ] if v is not None} + + if enabled or email_notifications_enabled: + job = utils.enable_org_webhook.queue(org) + data["job-id"] = job.id + + return jsonify(data), 201 if (not was_enabled and enabled) else 200 diff --git a/orcid_hub/models.py b/orcid_hub/models.py index 594a7eaa0..5f3e75948 100644 --- a/orcid_hub/models.py +++ b/orcid_hub/models.py @@ -1002,16 +1002,11 @@ class UserOrg(AuditedModel): user = ForeignKeyField(User, on_delete="CASCADE", index=True, backref="user_orgs") org = ForeignKeyField( Organisation, on_delete="CASCADE", index=True, verbose_name="Organisation", backref="user_orgs") - is_admin = BooleanField( null=True, default=False, help_text="User is an administrator for the organisation") # Affiliation bit-map: affiliations = SmallIntegerField(default=0, null=True, verbose_name="EDU Person Affiliations") - # created_by = ForeignKeyField( - # User, on_delete="SET NULL", null=True, backref="created_user_orgs") - # updated_by = ForeignKeyField( - # User, on_delete="SET NULL", null=True, backref="updated_user_orgs") # TODO: the access token should be either here or in a separate list # access_token = CharField(max_length=120, unique=True, null=True) @@ -3503,7 +3498,7 @@ class Meta: # noqa: D101,D106 class Invitee(BaseModel): """Common model bits of the invitees records.""" - identifier = CharField(max_length=120, verbose_name="Local Identifier", null=True) + identifier = CharField(max_length=120, null=True, verbose_name="Local Identifier") email = CharField(max_length=120, null=True) orcid = OrcidIdField(null=True) first_name = CharField(max_length=120, null=True) @@ -4038,7 +4033,7 @@ def index(ex): def val(row, column, default=None): idx = idxs.get(column) - if not idx or idx < 0 or idx >= len(row): + if idx is None or idx < 0 or idx >= len(row): return default return row[idx].strip() or default diff --git a/orcid_hub/templates/header.html b/orcid_hub/templates/header.html index ed2d868dc..e33b583cb 100644 --- a/orcid_hub/templates/header.html +++ b/orcid_hub/templates/header.html @@ -80,14 +80,16 @@ title="Import a works batch file">Works
  • Peer Reviews
  • -
  • Researcher Properties
  • +
  • Researcher Other IDs
  • Resources
  • diff --git a/orcid_hub/utils.py b/orcid_hub/utils.py index 3a8f240c8..23a11975f 100644 --- a/orcid_hub/utils.py +++ b/orcid_hub/utils.py @@ -27,11 +27,11 @@ from . import app, db, orcid_client, rq from .models import (AFFILIATION_TYPES, Affiliation, AffiliationRecord, Delegate, FundingInvitee, - FundingRecord, Log, MailLog, MessageRecord, NestedDict, Invitee, OtherIdRecord, - OrcidToken, Organisation, OrgInvitation, PartialDate, PeerReviewExternalId, - PeerReviewInvitee, PeerReviewRecord, PropertyRecord, ResourceRecord, Role, - Task, TaskType, User, UserInvitation, UserOrg, WorkInvitee, WorkRecord, - get_val, readup_file) + FundingRecord, Log, MailLog, MessageRecord, NestedDict, Invitee, + OtherIdRecord, OrcidToken, Organisation, OrgInvitation, PartialDate, + PeerReviewExternalId, PeerReviewInvitee, PeerReviewRecord, PropertyRecord, + RecordInvitee, ResourceRecord, Role, Task, TaskType, User, UserInvitation, + UserOrg, WorkInvitee, WorkRecord, get_val, readup_file) logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -525,10 +525,10 @@ def create_or_update_resources(user, org_id, records, *args, **kwargs): OrcidToken.user_id == user.id, OrcidToken.org_id == org.id, OrcidToken.scopes.contains("/activities/update")).first() api = orcid_client.MemberAPIV3(org, user, access_token=token.access_token) - resources = api.get_resources().get("group") + resources = api.get_resources() if resources: - + resources = resources.get("group") resources = [ r for r in resources if any( rr.get("source", "source-client-id", "path") == org.orcid_client_id @@ -552,7 +552,6 @@ def match_record(records, record): # if all(eid.get("external-id-value") != record.external_id_value # for eid in rr.get("proposal", "external-ids", "external-id")): # continue - if put_code in taken_put_codes: continue @@ -581,11 +580,18 @@ def match_record(records, record): resp = api.post("research-resource", rr.orcid_research_resource) if resp.status == 201: + orcid, put_code = resp.headers["Location"].split("/")[-3::2] rr.add_status_line("ORCID record was created.") else: + orcid = user.orcid rr.add_status_line("ORCID record was updated.") - if not put_code: - rr.put_code = resp.headers["Location"].split('/')[-1] + if not rr.put_code and put_code: + rr.put_code = int(put_code) + if not rr.orcid and orcid: + rr.orcid = orcid + visibility = json.loads(resp.data).get("visibility") if hasattr(resp, "data") else None + if rr.visibility != visibility: + rr.visibility = visibility except ApiException as ex: if ex.status == 404: @@ -2019,6 +2025,7 @@ def process_resource_records(max_rows=20, record_id=None): tasks = tasks.where(ResourceRecord.id.in_(record_id)) else: tasks = tasks.where(ResourceRecord.id == record_id) + for (task_id, org_id, user), tasks_by_user in groupby(tasks, lambda t: ( t.id, t.org_id, @@ -2304,7 +2311,6 @@ def register_orcid_webhook(user, callback_url=None, delete=False): If URL is given, it will be used for as call-back URL. """ local_handler = (callback_url is None) - # Don't delete the webhook if there is anyther organisation with enabled webhook: if local_handler and delete and user.organisations.where(Organisation.webhook_enabled).count() > 0: return @@ -2341,6 +2347,7 @@ def notify_about_update(user, event_type="UPDATED"): user.orcid, user.created_at or user.updated_at, user.updated_at or user.created_at, + apikey=org.webhook_apikey, event_type=event_type, append_orcid=org.webhook_append_orcid) @@ -2358,7 +2365,7 @@ def notify_about_update(user, event_type="UPDATED"): @rq.job(timeout=300) def invoke_webhook_handler(webhook_url=None, orcid=None, created_at=None, updated_at=None, message=None, - event_type="UPDATED", url=None, attempts=5, append_orcid=False): + apikey=None, event_type="UPDATED", url=None, attempts=5, append_orcid=False): """Propagate 'updated' event to the organisation event handler URL.""" if not message: url = app.config["ORCID_BASE_URL"] + orcid @@ -2386,7 +2393,10 @@ def invoke_webhook_handler(webhook_url=None, orcid=None, created_at=None, update url += orcid try: - resp = requests.post(url, json=message) + if apikey: + resp = requests.post(url, json=message, headers=dict(apikey=apikey)) + else: + resp = requests.post(url, json=message) except: if attempts == 1: raise @@ -2396,6 +2406,7 @@ def invoke_webhook_handler(webhook_url=None, orcid=None, created_at=None, update invoke_webhook_handler.schedule(timedelta(minutes=5 * (6 - attempts) if attempts < 6 else 5), message=message, + apikey=apikey, url=url, attempts=attempts - 1) else: @@ -2408,7 +2419,7 @@ def enable_org_webhook(org): """Enable Organisation Webhook.""" org.webhook_enabled = True org.save() - for u in org.users.where(User.webhook_enabled.NOT()): + for u in org.users.where(User.webhook_enabled.NOT(), User.orcid.is_null(False)): register_orcid_webhook.queue(u) @@ -2417,7 +2428,7 @@ def disable_org_webhook(org): """Disable Organisation Webhook.""" org.webhook_enabled = False org.save() - for u in org.users.where(User.webhook_enabled): + for u in org.users.where(User.webhook_enabled, User.orcid.is_null(False)): register_orcid_webhook.queue(u, delete=True) @@ -2518,7 +2529,7 @@ def dump_yaml(data): """Dump the objects into YAML representation.""" yaml.add_representer(datetime, SafeRepresenterWithISODate.represent_datetime, Dumper=Dumper) yaml.add_representer(defaultdict, SafeRepresenter.represent_dict) - return yaml.dump(data) + return yaml.dump(data, allow_unicode=True) def enqueue_user_records(user): @@ -2540,6 +2551,11 @@ def enqueue_user_records(user): records = records.join(WorkInvitee).where( (WorkInvitee.email.is_null() | (WorkInvitee.email == user.email)), (WorkInvitee.orcid.is_null() | (WorkInvitee.orcid == user.orcid))) + elif task.task_type == TaskType.RESOURCE and task.is_raw: + invitee_model = task.record_model.invitees.rel_model + records = records.join(RecordInvitee).join(Invitee).where( + (invitee_model.email.is_null() | (invitee_model.email == user.email)), + (invitee_model.orcid.is_null() | (invitee_model.orcid == user.orcid))) else: records = records.where( (task.record_model.email.is_null() | (task.record_model.email == user.email)), diff --git a/orcid_hub/views.py b/orcid_hub/views.py index 2ed6b5739..98af459d2 100644 --- a/orcid_hub/views.py +++ b/orcid_hub/views.py @@ -51,7 +51,7 @@ FundingInvitee, FundingRecord, Grant, GroupIdRecord, Invitee, MessageRecord, ModelException, NestedDict, OtherIdRecord, OrcidApiCall, OrcidToken, Organisation, OrgInfo, OrgInvitation, PartialDate, PropertyRecord, - PeerReviewExternalId, PeerReviewInvitee, PeerReviewRecord, Role, Task, + PeerReviewExternalId, PeerReviewInvitee, PeerReviewRecord, RecordInvitee, Role, Task, TaskType, TextField, Token, Url, User, UserInvitation, UserOrg, UserOrgAffiliation, WorkContributor, WorkExternalId, WorkInvitee, WorkRecord, db) @@ -193,6 +193,20 @@ def convert(self, model, field, field_args): class AppModelView(ModelView): """ModelView customization.""" + # def get_column_name(self, field): + # """ + # Return a human-readable column name. + + # :param field: + # Model field name. + # """ + # if self.column_labels and field in self.column_labels: + # return self.column_labels[field] + # else: + # model_field = self.model._meta.fields.get(field) + # return self._prettify_name( + # model_field.verbose_name if model_field and model_field.verbose_name else field) + roles = {1: "Superuser", 2: "Administrator", 4: "Researcher", 8: "Technical Contact"} column_editable_list = ["name", "is_active", "email", "role", "city", "region", "value", "url", "display_index"] roles_required = Role.SUPERUSER @@ -800,7 +814,7 @@ def action_activate(self, ids): try: status = "The record was activated at " + datetime.now().isoformat(timespec="seconds") count = self.model.update(is_active=True, status=status).where( - self.model.is_active == False, # noqa: E712 + ((self.model.is_active.is_null()) | (self.model.is_active == False)), # noqa: E712 self.model.id.in_(ids)).execute() if self.model == AffiliationRecord: records = self.model.select().where(self.model.id.in_(ids)).order_by( @@ -837,7 +851,13 @@ def action_reset(self, ids): processed_at=None, status=status).where(self.model.is_active, self.model.id.in_(ids)).execute() - if hasattr(self.model, "invitees"): + if task.is_raw: + invitee_ids = [i.id for i in Invitee.select().join( + RecordInvitee).join(MessageRecord).where(MessageRecord.id.in_(ids))] + count = Invitee.update( + processed_at=None, status=status).where(Invitee.id.in_(invitee_ids)).execute() + emails = Invitee.select(Invitee.email).where(Invitee.id.in_(invitee_ids)) + elif hasattr(self.model, "invitees"): im = self.model.invitees.rel_model count = im.update( processed_at=None, status=status).where(im.record.in_(ids)).execute() @@ -968,7 +988,7 @@ def current_record_id(self): record_id = request.args.get("record_id") if record_id: return int(record_id) - url = request.args.get("url") + url = request.values.get("url") or request.referrer if not url: flash("Missing return URL.", "danger") return None @@ -1036,18 +1056,20 @@ class InviteeAdmin(RecordChildAdmin): "Are you sure you want to reset the selected records for batch processing?") def action_reset(self, ids): """Batch reset of users.""" + rec_id = self.current_record_id with db.atomic() as transaction: try: status = " The record was reset at " + datetime.utcnow().isoformat(timespec="seconds") count = self.model.update( processed_at=None, status=status).where(self.model.id.in_(ids)).execute() - record_id = self.model.select().where( - self.model.id.in_(ids))[0].record_id - rec_class = self.model.record.rel_model + if self.model == Invitee and MessageRecord.select().where(MessageRecord.id == rec_id).exists(): + rec_class = MessageRecord + else: + rec_class = self.model.record.rel_model rec_class.update( processed_at=None, status=status).where( - rec_class.is_active, rec_class.id == record_id).execute() - getattr(utils, f"process_{rec_class.underscore_name()}s").queue(record_id) + rec_class.is_active, rec_class.id == rec_id).execute() + getattr(utils, f"process_{rec_class.underscore_name()}s").queue(rec_id) except Exception as ex: transaction.rollback() flash(f"Failed to activate the selected records: {ex}") @@ -1125,8 +1147,13 @@ def _export_tablib(self, export_type, return_url): try: try: - ds.yaml = yaml.safe_dump(json.loads(ds.json.replace("]\\", "]").replace("\\n", " "))) - response_data = ds.export(format=export_type) + if export_type == 'json': + response_data = json.dumps(json.loads(ds.json), ensure_ascii=False) + elif export_type == 'yaml': + response_data = yaml.safe_dump(json.loads(ds.json.replace("]\\", "]").replace("\\n", " ")), + allow_unicode=True) + else: + response_data = ds.export(format=export_type) except AttributeError: response_data = getattr(ds, export_type) except (AttributeError, tablib.UnsupportedFormat): @@ -1615,7 +1642,7 @@ class AffiliationRecordAdmin(CompositeRecordModelView): "start_date", "end_date", "city", - "state", + "region", "country", "disambiguated_id", "disambiguation_source", diff --git a/tests/conftest.py b/tests/conftest.py index a441ccfd5..eeb729a48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -80,6 +80,8 @@ class HubClient(FlaskClient): """Extension of the default Flask test client.""" resp_no = 0 + access_token = None + def login(self, user, affiliations=None, follow_redirects=False, **kwargs): """Log in with the given user.""" org = user.organisation or user.organisations.first() @@ -108,6 +110,21 @@ def login(self, user, affiliations=None, follow_redirects=False, **kwargs): def open(self, *args, **kwargs): """Save the last response.""" + # pre-encode API calls insead of making it to a form submission + # See: https://stackoverflow.com/questions/41653058/flask-testing-a-put-request-with-custom-headers + if "data" in kwargs and isinstance(args[0], str) and "/api/" in args[0] and isinstance( + kwargs["data"], dict): + kwargs["data"] = json.dumps(kwargs["data"]) + headers = kwargs.get("headers", dict()) + if "content-type" not in headers and "Content-Type" not in headers: + headers["content-type"] = "application/json" + kwargs["headers"] = headers + # add bearer access token if it's not in the headers + if self.access_token and isinstance(args[0], str) and "/api/" in args[0]: + headers = kwargs.get("headers", dict()) + if "authorization" not in headers and "authorization" not in headers: + headers["authorization"] = f"Bearer {self.access_token}" + kwargs["headers"] = headers self.resp = super().open(*args, **kwargs) if hasattr(self.resp, "data"): self.save_resp() diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 6cc4d3bc0..f113d3442 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -9,10 +9,10 @@ from flask_login import login_user import pytest -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import call, MagicMock, Mock, patch from orcid_hub import utils -from orcid_hub.models import Client, OrcidToken, Organisation, User, Token +from orcid_hub.models import Client, OrcidToken, Organisation, User, UserOrg, Token logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -364,17 +364,197 @@ def test_webhook_invokation(client, mocker): resp = client.post(f"/services/{user.id}/updated") assert resp.status_code == 204 schedule.assert_called_with(datetime.timedelta(seconds=300), + apikey=None, attempts=4, message=message, url=f"http://test.edu/{user.orcid}") - post = mocker.patch.object(utils.requests, "post", side_effect=Exception("OH! NOHHH!")) + Organisation.update(webhook_apikey="ABC123").execute() + schedule.reset_mock() resp = client.post(f"/services/{user.id}/updated") - assert resp.status_code == 204 schedule.assert_called_with(datetime.timedelta(seconds=300), + apikey="ABC123", attempts=4, message=message, url=f"http://test.edu/{user.orcid}") + Organisation.update(webhook_apikey=None).execute() + post = mocker.patch.object(utils.requests, "post", side_effect=Exception("OH! NOHHH!")) + schedule.reset_mock() + resp = client.post(f"/services/{user.id}/updated") + assert resp.status_code == 204 + schedule.assert_not_called() + with pytest.raises(Exception): utils.invoke_webhook_handler(attempts=1, message=message, url=f"http://test.edu/{user.orcid}") + + +def test_org_webhook_api(client, mocker): + """Test Organisation webhooks.""" + mocker.patch.object( + utils.requests, "post", + lambda *args, **kwargs: Mock( + status_code=201, + json=lambda: dict(access_token="ABC123", refresh_token="REFRESH_ME", expires_in=123456789))) + + mockput = mocker.patch.object(utils.requests, "put") + mockdelete = mocker.patch.object(utils.requests, "delete") + + org = client.data["org"] + admin = org.tech_contact + + send_email = mocker.patch("orcid_hub.utils.send_email") + + api_client = Client.get(org=org) + + resp = client.post( + "/oauth/token", + data=dict( + grant_type="client_credentials", + client_id=api_client.client_id, + client_secret=api_client.client_secret, + scope="/webhook")) + + assert resp.status_code == 200 + data = json.loads(resp.data) + api_client = Client.get(client_id="CLIENT_ID") + token = Token.select().where(Token.user == admin, Token._scopes == "/webhook").first() + + assert data["access_token"] == token.access_token + assert data["expires_in"] == client.application.config["OAUTH2_PROVIDER_TOKEN_EXPIRES_IN"] + assert data["token_type"] == token.token_type + + client.access_token = token.access_token + + resp = client.put("/api/v1/webhook/INCORRECT-WEBHOOK-URL") + assert resp.status_code == 415 + assert json.loads(resp.data) == { + "error": "Invalid call-back URL", + "message": "Invalid call-back URL: INCORRECT-WEBHOOK-URL" + } + + # Webhook registration response: + mockresp = MagicMock(status_code=201, data=b'') + mockresp.headers = { + "Server": "TEST123", + "Connection": "keep-alive", + "Pragma": "no-cache", + "Expires": "0", + } + mockput.return_value = mockresp + + # Webhook deletion response: + mockresp = MagicMock(status_code=204, data=b'') + mockresp.headers = { + "Seresper": "TEST123", + "Connection": "keep-alive", + "Location": "TEST-LOCATION", + "Pragma": "no-cache", + "Expires": "0", + } + mockdelete.return_value = mockresp + + resp = client.put("/api/v1/webhook/http%3A%2F%2FCALL-BACK") + assert resp.status_code == 200 + + resp = client.put("/api/v1/webhook/http%3A%2F%2FCALL-BACK", + data=dict(enabled=True, url="https://CALL-BACK.edu/callback")) + assert resp.status_code == 201 + + mockput.assert_has_calls([ + call( + "https://api.sandbox.orcid.org/1001-0001-0001-0001/webhook/%2Fservices%2F21%2Fupdated", + headers={ + "Accept": "application/json", + "Authorization": "Bearer ABC123", + "Content-Length": "0" + }), + call( + "https://api.sandbox.orcid.org/0000-0000-0000-00X3/webhook/%2Fservices%2F22%2Fupdated", + headers={ + "Accept": "application/json", + "Authorization": "Bearer ABC123", + "Content-Length": "0" + }), + call( + "https://api.sandbox.orcid.org/0000-0000-0000-11X2/webhook/%2Fservices%2F30%2Fupdated", + headers={ + "Accept": "application/json", + "Authorization": "Bearer ABC123", + "Content-Length": "0" + }) + ]) + + q = OrcidToken.select().where(OrcidToken.org == org, OrcidToken.scopes == "/webhook") + assert q.exists() + assert q.count() == 1 + orcid_token = q.first() + assert orcid_token.access_token == "ABC123" + assert orcid_token.refresh_token == "REFRESH_ME" + assert orcid_token.expires_in == 123456789 + assert orcid_token.scopes == "/webhook" + + # deactivate: + + resp = client.delete(f"/api/v1/webhook/http%3A%2F%2FCALL-BACK") + assert resp.status_code == 200 + assert "job-id" in resp.json + + # activate with all options: + mockput.reset_mock() + resp = client.put("/api/v1/webhook", + data={ + "enabled": True, + "append-orcid": True, + "apikey": "APIKEY123", + "email-notifications-enabled": True, + "notification-email": "notify_me@org.edu", + }) + mockput.assert_has_calls([ + call( + "https://api.sandbox.orcid.org/1001-0001-0001-0001/webhook/%2Fservices%2F21%2Fupdated", + headers={ + "Accept": "application/json", + "Authorization": "Bearer ABC123", + "Content-Length": "0" + }), + call( + "https://api.sandbox.orcid.org/0000-0000-0000-00X3/webhook/%2Fservices%2F22%2Fupdated", + headers={ + "Accept": "application/json", + "Authorization": "Bearer ABC123", + "Content-Length": "0" + }), + call( + "https://api.sandbox.orcid.org/0000-0000-0000-11X2/webhook/%2Fservices%2F30%2Fupdated", + headers={ + "Accept": "application/json", + "Authorization": "Bearer ABC123", + "Content-Length": "0" + }) + ]) + + # Link other org to the users + org2 = Organisation.select().where(Organisation.id != org.id).first() + UserOrg.insert_many([dict(user_id=u.id, org_id=org2.id) for u in org.users]).execute() + org2.webhook_enabled = True + org2.save() + resp = client.delete(f"/api/v1/webhook") + + mockput.reset_mock() + resp = client.put("/api/v1/webhook", + data={ + "enabled": False, + "url": "https://CALL-BACK.edu/callback", + "append-orcid": False, + "email-notifications-enabled": True, + "notification-email": "notify_me@org.edu", + }) + mockput.assert_not_called() + + # Test update summary: + User.update(orcid_updated_at=datetime.date.today().replace(day=1) - datetime.timedelta(days=15)).execute() + send_email.reset_mock() + utils.send_orcid_update_summary() + send_email.assert_called_once() + client.logout()