diff --git a/.env.dist b/.env.dist
index 518ad98a..b9e98292 100644
--- a/.env.dist
+++ b/.env.dist
@@ -23,3 +23,8 @@ DJANGO_EMAIL_PORT=
DJANGO_EMAIL_HOST_USER=
DJANGO_EMAIL_HOST_PASSWORD=
DJANGO_EMAIL_USE_TLS=
+
+# drupal api
+DRUPAL_API_CLIENT_ID=
+DRUPAL_API_CLIENT_SECRET=
+DRUPAL_API_REL_PATH=
diff --git a/config/settings/base.py b/config/settings/base.py
index 3ef49a5a..01092f24 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -403,3 +403,13 @@
# Specify the subject for AnVIL account verification emails.
ANVIL_ACCOUNT_LINK_EMAIL_SUBJECT = "Verify your AnVIL account email"
ANVIL_ACCOUNT_VERIFY_NOTIFICATION_EMAIL = "primedconsortium@uw.edu"
+
+DRUPAL_API_CLIENT_ID = env("DRUPAL_API_CLIENT_ID", default="")
+DRUPAL_API_CLIENT_SECRET = env("DRUPAL_API_CLIENT_SECRET", default="")
+DRUPAL_API_REL_PATH = env("DRUPAL_API_REL_PATH", default="mockapi")
+DRUPAL_DATA_AUDIT_DEACTIVATE_USERS = env(
+ "DRUPAL_DATA_AUDIT_DEACTIVATE_USERS", default=False
+)
+DRUPAL_DATA_AUDIT_REMOVE_USER_SITES = env(
+ "DRUPAL_DATA_AUDIT_REMOVE_USER_SITES", default=False
+)
diff --git a/primed/primed_anvil/admin.py b/primed/primed_anvil/admin.py
index ce6d19ee..a4265dad 100644
--- a/primed/primed_anvil/admin.py
+++ b/primed/primed_anvil/admin.py
@@ -26,18 +26,9 @@ class StudyAdmin(SimpleHistoryAdmin):
class StudySiteAdmin(admin.ModelAdmin):
"""Admin class for the `Study` model."""
- list_display = (
- "short_name",
- "full_name",
- )
- search_fields = (
- "short_name",
- "full_name",
- )
- sortable_by = (
- "short_name",
- "full_name",
- )
+ list_display = ("short_name", "full_name", "drupal_node_id")
+ search_fields = ("short_name", "full_name", "drupal_node_id")
+ sortable_by = ("short_name", "full_name", "drupal_node_id")
@admin.register(models.AvailableData)
diff --git a/primed/primed_anvil/migrations/0006_studysite_drupal_node_id.py b/primed/primed_anvil/migrations/0006_studysite_drupal_node_id.py
new file mode 100644
index 00000000..91ee9bd1
--- /dev/null
+++ b/primed/primed_anvil/migrations/0006_studysite_drupal_node_id.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.19 on 2023-12-06 16:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('primed_anvil', '0005_availabledata'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='studysite',
+ name='drupal_node_id',
+ field=models.IntegerField(blank=True, null=True),
+ ),
+ ]
diff --git a/primed/primed_anvil/models.py b/primed/primed_anvil/models.py
index 528d85ca..2277c9b8 100644
--- a/primed/primed_anvil/models.py
+++ b/primed/primed_anvil/models.py
@@ -14,6 +14,7 @@ class Study(TimeStampedModel, models.Model):
full_name = models.CharField(
max_length=255, help_text="The full name for this Study."
)
+
history = HistoricalRecords()
class Meta:
@@ -32,13 +33,16 @@ def get_absolute_url(self):
class StudySite(TimeStampedModel, models.Model):
- """A model to track Research Centers."""
+ """A model to track Study Sites."""
short_name = models.CharField(max_length=15, unique=True)
- """The short name of the Research Center."""
+ """The short name of the Study Sites."""
full_name = models.CharField(max_length=255)
- """The full name of the Research Center."""
+ """The full name of the Study Sites."""
+
+ drupal_node_id = models.IntegerField(blank=True, null=True)
+ """Reference node ID for entity in drupal"""
def __str__(self):
"""String method.
diff --git a/primed/templates/users/drupal_data_audit_email.html b/primed/templates/users/drupal_data_audit_email.html
new file mode 100644
index 00000000..c957a32f
--- /dev/null
+++ b/primed/templates/users/drupal_data_audit_email.html
@@ -0,0 +1,47 @@
+
+{% load static i18n %}
+{% load render_table from django_tables2 %}
+
+
+
+ Drupal Data Audit Report
+
+
+
+
+{% block content %}
+
+
Drupal Data Audit - [applying_changes={{ apply_changes }}]
+ User Audit
+
+ Verified Users - {{ user_audit.verified|length }} record(s)
+
+ Needs action - {{user_audit.needs_action|length }} record(s)
+ {% if user_audit.needs_action %}
+ {% render_table user_audit.get_needs_action_table %}
+ {% endif %}
+
+ Errors - {{user_audit.errors|length }} record(s)
+ {% if user_audit.errors %}
+ {% render_table user_audit.get_errors_table %}
+ {% endif %}
+
+ Site Audit
+
+ Verified sites - {{ site_audit.verified|length }} record(s)
+
+ Sites that need action - {{site_audit.needs_action|length }} record(s)
+ {% if site_audit.needs_action %}
+ {% render_table site_audit.get_needs_action_table %}
+ {% endif %}
+
+ Sites with errors - {{site_audit.errors|length }} record(s)
+ {% if site_audit.errors %}
+ {% render_table site_audit.get_errors_table %}
+ {% endif %}
+
+
+{% endblock content %}
+
+
+
diff --git a/primed/users/adapters.py b/primed/users/adapters.py
index b0f6d6a3..24e6d91e 100644
--- a/primed/users/adapters.py
+++ b/primed/users/adapters.py
@@ -23,7 +23,7 @@ class SocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request: HttpRequest, sociallogin: Any):
return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True)
- def update_user_info(self, user, extra_data: Dict):
+ def update_user_info(self, user, extra_data: Dict, apply_update=True):
drupal_username = extra_data.get("preferred_username")
drupal_email = extra_data.get("email")
first_name = extra_data.get("first_name")
@@ -52,13 +52,15 @@ def update_user_info(self, user, extra_data: Dict):
user.email = drupal_email
user_changed = True
- if user_changed is True:
+ if user_changed is True and apply_update is True:
user.save()
+ return user_changed
- def update_user_study_sites(self, user, extra_data: Dict):
+ def update_user_study_sites(self, user, extra_data: Dict, apply_update=True):
# Get list of research centers in domain table
research_center_or_site = extra_data.get("study_site_or_center")
+ user_sites_updated = False
if research_center_or_site:
if not isinstance(research_center_or_site, list):
raise ImproperlyConfigured(
@@ -79,19 +81,24 @@ def update_user_study_sites(self, user, extra_data: Dict):
continue
else:
if not user.study_sites.filter(pk=rc.pk):
- user.study_sites.add(rc)
- logger.info(
- f"[SocialAccountAdatpter:update_user_study_sites] adding user "
- f"study_sites user: {user} rc: {rc}"
- )
+ user_sites_updated = True
+ if apply_update is True:
+ user.study_sites.add(rc)
+ logger.info(
+ f"[SocialAccountAdatpter:update_user_study_sites] adding user "
+ f"study_sites user: {user} rc: {rc}"
+ )
for existing_rc in user.study_sites.all():
if existing_rc.short_name not in research_center_or_site:
- user.study_sites.remove(existing_rc)
- logger.info(
- "[SocialAccountAdatpter:update_user_study_sites] "
- f"removing study_site {existing_rc} for user {user}"
- )
+ user_sites_updated = True
+ if apply_update:
+ user.study_sites.remove(existing_rc)
+ logger.info(
+ "[SocialAccountAdatpter:update_user_study_sites] "
+ f"removing study_site {existing_rc} for user {user}"
+ )
+ return user_sites_updated
def update_user_groups(self, user, extra_data: Dict):
managed_scope_status = extra_data.get("managed_scope_status")
diff --git a/primed/users/audit.py b/primed/users/audit.py
new file mode 100644
index 00000000..371d7760
--- /dev/null
+++ b/primed/users/audit.py
@@ -0,0 +1,527 @@
+import logging
+from dataclasses import dataclass
+
+import django_tables2 as tables
+import jsonapi_requests
+from allauth.socialaccount.models import SocialAccount
+from anvil_consortium_manager.models import Account
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ObjectDoesNotExist
+from django.db.models import Q
+from django_tables2.export import TableExport
+from oauthlib.oauth2 import BackendApplicationClient
+from requests_oauthlib import OAuth2, OAuth2Session
+
+from primed.drupal_oauth_provider.provider import CustomProvider
+from primed.primed_anvil.audit import PRIMEDAudit, PRIMEDAuditResult
+from primed.primed_anvil.models import StudySite
+
+logger = logging.getLogger(__name__)
+
+
+class TextTable(object):
+ def render_to_text(self):
+ return TableExport(export_format=TableExport.CSV, table=self).export()
+
+
+class UserAuditResultsTable(tables.Table, TextTable):
+ """A table to show results from a UserAudit instance."""
+
+ result_type = tables.Column()
+ local_user_id = tables.Column()
+ local_username = tables.Column()
+ remote_user_id = tables.Column()
+ remote_username = tables.Column()
+ changes = tables.Column()
+ note = tables.Column()
+ anvil_groups = tables.Column()
+
+ class Meta:
+ orderable = False
+
+
+@dataclass
+class UserAuditResult(PRIMEDAuditResult):
+ local_user: SocialAccount = None
+ anvil_account: Account = None
+ remote_user_data: jsonapi_requests.JsonApiObject = None
+ note: str = None
+ changes: dict = None
+ anvil_groups: list = None
+
+ def get_table_dictionary(self):
+ """Return a dictionary that can be used to populate an instance of `SiteAuditResultsTable`."""
+
+ row = {
+ "changes": self.changes,
+ "anvil_groups": self.anvil_groups,
+ "note": self.note,
+ "result_type": type(self).__name__,
+ }
+ if self.local_user:
+ row.update(
+ {
+ "local_user_id": self.local_user.user.id,
+ "local_username": self.local_user.user.username,
+ }
+ )
+ if self.remote_user_data:
+ row.update(
+ {
+ "remote_user_id": self.remote_user_data.attributes.get(
+ "drupal_internal__uid"
+ ),
+ "remote_username": self.remote_user_data.attributes.get("name"),
+ }
+ )
+ if self.anvil_account:
+ row.update(
+ {
+ "anvil_account": self.anvil_account,
+ "local_user_id": self.anvil_account.user.id,
+ }
+ )
+ return row
+
+
+@dataclass
+class VerifiedUser(UserAuditResult):
+ pass
+
+
+@dataclass
+class NewUser(UserAuditResult):
+ pass
+
+
+@dataclass
+class RemoveUser(UserAuditResult):
+ pass
+
+
+@dataclass
+class InactiveAnvilUser(UserAuditResult):
+ pass
+
+
+@dataclass
+class UpdateUser(UserAuditResult):
+ pass
+
+
+@dataclass
+class OverDeactivateThresholdUser(UserAuditResult):
+ pass
+
+
+class UserAudit(PRIMEDAudit):
+ ISSUE_TYPE_USER_INACTIVE = "User is inactive in drupal"
+ ISSUE_TYPE_USER_REMOVED_FROM_SITE = "User removed from site"
+ USER_DEACTIVATE_THRESHOLD = 3
+ results_table_class = UserAuditResultsTable
+
+ def __init__(self, apply_changes=False, ignore_deactivate_threshold=False):
+ """Initialize the audit.
+
+ Args:
+ apply_changes: Whether to make changes to align the audit
+ """
+ super().__init__()
+ self.apply_changes = apply_changes
+ self.ignore_deactivate_threshold = ignore_deactivate_threshold
+
+ def _run_audit(self):
+ """Run the audit on local and remote users."""
+ user_endpoint_url = "user/user"
+ drupal_uids = set()
+ json_api = get_drupal_json_api()
+ study_sites = get_study_sites(json_api)
+
+ user_count = 0
+ while user_endpoint_url is not None:
+
+ users_endpoint = json_api.endpoint(user_endpoint_url)
+ users_endpoint_response = users_endpoint.get()
+
+ # If there are more, there will be a 'next' link
+
+ user_endpoint_url = users_endpoint_response.content.links.get(
+ "next", {}
+ ).get("href")
+
+ for user in users_endpoint_response.data:
+ drupal_uid = user.attributes.get("drupal_internal__uid")
+ drupal_username = user.attributes.get("name")
+ drupal_email = user.attributes.get("mail")
+ drupal_firstname = user.attributes.get("field_given_first_name_s_")
+ drupal_lastname = user.attributes.get(
+ "field_examples_family_last_name_"
+ )
+ drupal_full_name = " ".join(
+ part for part in (drupal_firstname, drupal_lastname) if part
+ )
+ drupal_study_sites_rel = user.relationships.get(
+ "field_study_site_or_center"
+ )
+ drupal_user_study_site_shortnames = []
+ if drupal_study_sites_rel:
+ for dss in drupal_study_sites_rel.data:
+ study_site_uuid = dss.id
+ study_site_info = study_sites[study_site_uuid]
+
+ drupal_user_study_site_shortnames.append(
+ study_site_info["short_name"]
+ )
+ new_user_sites = StudySite.objects.filter(
+ short_name__in=drupal_user_study_site_shortnames
+ )
+ # no uid is blocked or anonymous
+ if not drupal_uid:
+ # potential blocked user, but will no longer have a drupal uid
+ # so we cover these below
+ continue
+ sa = None
+ try:
+ sa = SocialAccount.objects.get(
+ uid=user.attributes["drupal_internal__uid"],
+ provider=CustomProvider.id,
+ )
+ except ObjectDoesNotExist:
+ drupal_user = get_user_model()()
+ drupal_user.username = drupal_username
+ drupal_user.name = drupal_full_name
+ drupal_user.email = drupal_email
+ if self.apply_changes is True:
+ drupal_user.save()
+ drupal_user.study_sites.set(new_user_sites)
+ if self.apply_changes is True:
+ sa = SocialAccount.objects.create(
+ user=drupal_user,
+ uid=user.attributes["drupal_internal__uid"],
+ provider=CustomProvider.id,
+ )
+ self.needs_action.append(
+ NewUser(local_user=sa, remote_user_data=user)
+ )
+
+ if sa:
+ user_updates = {}
+ if sa.user.name != drupal_full_name:
+ user_updates.update(
+ {"name": {"old": sa.user.name, "new": drupal_full_name}}
+ )
+ sa.user.name = drupal_full_name
+ if sa.user.username != drupal_username:
+ user_updates.update(
+ {
+ "username": {
+ "old": sa.user.username,
+ "new": drupal_username,
+ }
+ }
+ )
+ sa.user.username = drupal_username
+ if sa.user.email != drupal_email:
+ user_updates.update(
+ {"email": {"old": sa.user.email, "new": drupal_email}}
+ )
+ sa.user.email = drupal_email
+
+ if sa.user.is_active is False:
+ user_updates.update({"is_active": {"old": False, "new": True}})
+ sa.user.is_active = True
+
+ prev_user_site_names = set(
+ sa.user.study_sites.all().values_list("short_name", flat=True)
+ )
+ new_user_site_names = set(drupal_user_study_site_shortnames)
+ if prev_user_site_names != new_user_site_names:
+
+ user_updates.update(
+ {
+ "sites": {
+ "old": prev_user_site_names,
+ "new": new_user_site_names,
+ }
+ }
+ )
+ # do not remove from sites by default
+ removed_sites = prev_user_site_names.difference(
+ new_user_site_names
+ )
+ new_sites = new_user_site_names.difference(prev_user_site_names)
+
+ if settings.DRUPAL_DATA_AUDIT_REMOVE_USER_SITES is True:
+ if self.apply_changes is True:
+ sa.user.study_sites.set(new_user_sites)
+ else:
+ if removed_sites:
+ self.errors.append(
+ UpdateUser(
+ local_user=sa,
+ remote_user_data=user,
+ changes=user_updates,
+ )
+ )
+ if new_sites:
+ for new_site in new_user_sites:
+ if new_site.short_name in new_user_site_names:
+ if self.apply_changes is True:
+ sa.user.study_sites.add(new_site)
+
+ if user_updates:
+ if self.apply_changes is True:
+ sa.user.save()
+
+ self.needs_action.append(
+ UpdateUser(
+ local_user=sa,
+ remote_user_data=user,
+ changes=user_updates,
+ )
+ )
+ else:
+ self.verified.append(
+ VerifiedUser(local_user=sa, remote_user_data=user)
+ )
+
+ drupal_uids.add(drupal_uid)
+ user_count += 1
+
+ # find active django accounts that are drupal based
+ # users that we did not get from drupal
+ # these may include blocked users
+
+ unaudited_drupal_accounts = SocialAccount.objects.filter(
+ provider=CustomProvider.id, user__is_active=True
+ ).exclude(uid__in=drupal_uids)
+ user_ids_to_check = []
+ count_inactive = unaudited_drupal_accounts.count()
+ over_threshold = False
+ if self.ignore_deactivate_threshold is False:
+ if count_inactive > self.USER_DEACTIVATE_THRESHOLD:
+ over_threshold = True
+
+ for uda in unaudited_drupal_accounts:
+ user_ids_to_check.append(uda.user.id)
+ handled = False
+ if settings.DRUPAL_DATA_AUDIT_DEACTIVATE_USERS is True:
+ uda.user.is_active = False
+ if over_threshold is False:
+ if self.apply_changes is True:
+ uda.user.save()
+ handled = True
+ self.needs_action.append(RemoveUser(local_user=uda))
+ if handled is False:
+ self.errors.append(
+ RemoveUser(local_user=uda, note=f"Over Threshold {over_threshold}")
+ )
+
+ inactive_anvil_users = Account.objects.filter(
+ Q(user__is_active=False) | Q(user__id__in=user_ids_to_check),
+ groupaccountmembership__isnull=False,
+ )
+ for inactive_anvil_user in inactive_anvil_users:
+ self.errors.append(
+ InactiveAnvilUser(
+ anvil_account=inactive_anvil_user,
+ anvil_groups=list(
+ inactive_anvil_user.groupaccountmembership_set.all().values_list(
+ "group__name", flat=True
+ )
+ ),
+ )
+ )
+
+
+class SiteAuditResultsTable(tables.Table, TextTable):
+ """A table to show results from a SiteAudit instance."""
+
+ result_type = tables.Column()
+ local_site_name = tables.Column()
+ remote_site_name = tables.Column()
+ changes = tables.Column()
+ note = tables.Column()
+
+ def value_local_site_name(self, value):
+ return value
+
+ class Meta:
+ orderable = False
+
+
+@dataclass
+class SiteAuditResult(PRIMEDAuditResult):
+ local_site: StudySite
+ remote_site_data: jsonapi_requests.JsonApiObject = None
+ changes: dict = None
+ note: str = None
+
+ def get_table_dictionary(self):
+ """Return a dictionary that can be used to populate an instance of `SiteAuditResultsTable`."""
+ row = {
+ "changes": self.changes,
+ "note": self.note,
+ "result_type": type(self).__name__,
+ }
+ if self.local_site:
+ row.update(
+ {
+ "local_site_name": self.local_site.short_name,
+ }
+ )
+ if self.remote_site_data:
+ row.update(
+ {
+ "remote_site_name": self.remote_site_data.get("short_name"),
+ }
+ )
+ return row
+
+
+@dataclass
+class VerifiedSite(SiteAuditResult):
+ pass
+
+
+@dataclass
+class NewSite(SiteAuditResult):
+ pass
+
+
+@dataclass
+class RemoveSite(SiteAuditResult):
+ pass
+
+
+@dataclass
+class UpdateSite(SiteAuditResult):
+ changes: dict
+
+
+class SiteAudit(PRIMEDAudit):
+ ISSUE_TYPE_LOCAL_SITE_INVALID = "Local site is invalid"
+ results_table_class = SiteAuditResultsTable
+
+ def __init__(self, apply_changes=False):
+ """Initialize the audit.
+
+ Args:
+ apply_changes: Whether to make changes to align the audit
+ """
+ super().__init__()
+ self.apply_changes = apply_changes
+
+ def _run_audit(self):
+ """Run the audit on local and remote users."""
+ valid_nodes = set()
+ json_api = get_drupal_json_api()
+ study_sites = get_study_sites(json_api=json_api)
+ for study_site_info in study_sites.values():
+
+ short_name = study_site_info["short_name"]
+ full_name = study_site_info["full_name"]
+ node_id = study_site_info["node_id"]
+ valid_nodes.add(node_id)
+
+ try:
+ study_site = StudySite.objects.get(drupal_node_id=node_id)
+ except ObjectDoesNotExist:
+ study_site = None
+ if self.apply_changes is True:
+ study_site = StudySite.objects.create(
+ drupal_node_id=node_id,
+ short_name=short_name,
+ full_name=full_name,
+ )
+ self.needs_action.append(
+ NewSite(remote_site_data=study_site_info, local_site=study_site)
+ )
+ else:
+ study_site_updates = {}
+
+ if study_site.full_name != full_name:
+ study_site_updates.update(
+ {"full_name": {"old": study_site.full_name, "new": full_name}}
+ )
+ study_site.full_name = full_name
+
+ if study_site.short_name != short_name:
+ study_site_updates.update(
+ {
+ "short_name": {
+ "old": study_site.short_name,
+ "new": short_name,
+ }
+ }
+ )
+ study_site.short_name = short_name
+
+ if study_site_updates:
+ if self.apply_changes is True:
+ study_site.save()
+ self.needs_action.append(
+ UpdateSite(
+ local_site=study_site,
+ remote_site_data=study_site_info,
+ changes=study_site_updates,
+ )
+ )
+ else:
+ self.verified.append(
+ VerifiedSite(
+ local_site=study_site, remote_site_data=study_site_info
+ )
+ )
+
+ invalid_study_sites = StudySite.objects.exclude(drupal_node_id__in=valid_nodes)
+
+ for iss in invalid_study_sites:
+ self.errors.append(
+ RemoveSite(local_site=iss, note=self.ISSUE_TYPE_LOCAL_SITE_INVALID)
+ )
+
+
+def get_drupal_json_api():
+
+ json_api_client_id = settings.DRUPAL_API_CLIENT_ID
+ json_api_client_secret = settings.DRUPAL_API_CLIENT_SECRET
+
+ token_url = f"{settings.DRUPAL_SITE_URL}/oauth/token"
+ client = BackendApplicationClient(client_id=json_api_client_id)
+ oauth = OAuth2Session(client=client)
+ api_root = f"{settings.DRUPAL_SITE_URL}/{settings.DRUPAL_API_REL_PATH}"
+
+ token = oauth.fetch_token(
+ token_url=token_url,
+ client_id=json_api_client_id,
+ client_secret=json_api_client_secret,
+ )
+
+ drupal_api = jsonapi_requests.Api.config(
+ {
+ "API_ROOT": api_root,
+ "AUTH": OAuth2(client=client, client_id=json_api_client_id, token=token),
+ "VALIDATE_SSL": True,
+ }
+ )
+ return drupal_api
+
+
+def get_study_sites(json_api):
+ study_sites_endpoint = json_api.endpoint("node/study_site_or_center")
+ study_sites_response = study_sites_endpoint.get()
+ study_sites_info = dict()
+
+ for ss in study_sites_response.data:
+ short_name = ss.attributes["title"]
+ full_name = ss.attributes["field_long_name"]
+ node_id = ss.attributes["drupal_internal__nid"]
+
+ study_sites_info[ss.id] = {
+ "node_id": node_id,
+ "short_name": short_name,
+ "full_name": full_name,
+ }
+ return study_sites_info
diff --git a/primed/users/management/__init__.py b/primed/users/management/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/primed/users/management/commands/__init__.py b/primed/users/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/primed/users/management/commands/sync-drupal-data.py b/primed/users/management/commands/sync-drupal-data.py
new file mode 100644
index 00000000..d58113cc
--- /dev/null
+++ b/primed/users/management/commands/sync-drupal-data.py
@@ -0,0 +1,105 @@
+import logging
+
+from django.core.mail import send_mail
+from django.core.management.base import BaseCommand
+from django.http import HttpRequest
+from django.template.loader import render_to_string
+from django.utils.timezone import localtime
+
+from primed.users import audit
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = "Sync drupal user and domain data"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--update",
+ action="store_true",
+ dest="update",
+ default=False,
+ help="Make updates to sync local data with remote. If not set, will just report.",
+ )
+ parser.add_argument(
+ "--ignore-threshold",
+ action="store_true",
+ dest="ignore_threshold",
+ default=False,
+ help="Ignore user deactivation threshold",
+ )
+
+ parser.add_argument(
+ "--email",
+ help="""Email to which to send audit result details that need action or have errors.""",
+ )
+
+ def _send_email(self, user_audit, site_audit):
+ # Send email if requested and there are problems.
+ if user_audit.ok() is False or site_audit.ok() is False:
+ # django-tables2 requires request context, so we create an empty one
+ # if we wanted to linkify any of our data we would need to do more here
+ request = HttpRequest()
+ subject = "[command:sync-drupal-data] report"
+ html_body = render_to_string(
+ "users/drupal_data_audit_email.html",
+ context={
+ "user_audit": user_audit,
+ "site_audit": site_audit,
+ "request": request,
+ "apply_changes": self.apply_changes,
+ },
+ )
+ send_mail(
+ subject,
+ "Drupal data audit problems or changes found. Please see attached report.",
+ None,
+ [self.email],
+ fail_silently=False,
+ html_message=html_body,
+ )
+
+ def handle(self, *args, **options):
+ self.apply_changes = options.get("update")
+ self.email = options["email"]
+ self.ignore_threshold = options["ignore_threshold"]
+
+ notification_content = (
+ f"[sync-drupal-data] start: Applying Changes: {self.apply_changes} "
+ f"Ignoring Threshold: {self.ignore_threshold} Start time: {localtime()}\n"
+ )
+ site_audit = audit.SiteAudit(apply_changes=self.apply_changes)
+ site_audit.run_audit()
+
+ notification_content += (
+ f"SiteAudit summary: status ok: {site_audit.ok()} verified: {len(site_audit.verified)} "
+ f"needs_changes: {len(site_audit.needs_action)} errors: {len(site_audit.errors)}\n"
+ )
+ if site_audit.needs_action:
+ notification_content += "Sites that need syncing:\n"
+ notification_content += site_audit.get_needs_action_table().render_to_text()
+ if site_audit.errors:
+ notification_content += "Sites requiring intervention:\n"
+ notification_content += site_audit.get_errors_table().render_to_text()
+
+ user_audit = audit.UserAudit(
+ apply_changes=self.apply_changes,
+ ignore_deactivate_threshold=self.ignore_threshold,
+ )
+ user_audit.run_audit()
+ notification_content += (
+ "--------------------------------------\n"
+ f"UserAudit summary: status ok: {user_audit.ok()} verified: {len(user_audit.verified)} "
+ f"needs_changes: {len(user_audit.needs_action)} errors: {len(user_audit.errors)}\n"
+ )
+ if user_audit.needs_action:
+ notification_content += "Users that need syncing:\n"
+ notification_content += user_audit.get_needs_action_table().render_to_text()
+ if user_audit.errors:
+ notification_content += "Users that need intervention:\n"
+ notification_content += user_audit.get_errors_table().render_to_text()
+
+ self.stdout.write(notification_content)
+ if self.email:
+ self._send_email(user_audit, site_audit)
diff --git a/primed/users/tests/test_audit.py b/primed/users/tests/test_audit.py
new file mode 100644
index 00000000..155d114e
--- /dev/null
+++ b/primed/users/tests/test_audit.py
@@ -0,0 +1,612 @@
+import json
+import time
+from io import StringIO
+
+import responses
+from allauth.socialaccount.models import SocialAccount
+from anvil_consortium_manager.models import (
+ Account,
+ GroupAccountMembership,
+ ManagedGroup,
+)
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core import mail
+from django.core.management import call_command
+from django.test import TestCase
+from marshmallow_jsonapi import Schema, fields
+
+from primed.drupal_oauth_provider.provider import CustomProvider
+from primed.users import audit
+from primed.users.models import StudySite
+
+
+class StudySiteMockObject:
+ def __init__(self, id, title, field_long_name, drupal_internal__nid) -> None:
+ self.id = id
+ self.title = title
+ self.field_long_name = field_long_name
+ self.drupal_internal__nid = drupal_internal__nid
+
+
+class UserMockObject:
+ def __init__(
+ self,
+ id,
+ display_name,
+ drupal_internal__uid,
+ name,
+ mail,
+ field_given_first_name_s_,
+ field_examples_family_last_name_,
+ field_study_site_or_center,
+ ) -> None:
+ self.id = id
+ self.display_name = display_name
+ self.drupal_internal__uid = drupal_internal__uid
+ self.name = name
+ self.mail = mail
+ self.field_given_first_name_s_ = field_given_first_name_s_
+ self.field_examples_family_last_name_ = field_examples_family_last_name_
+ self.field_study_site_or_center = field_study_site_or_center
+
+
+class StudySiteSchema(Schema):
+ id = fields.Str(dump_only=True)
+ title = fields.Str()
+ field_long_name = fields.Str()
+ drupal_internal__nid = fields.Str()
+ # document_meta = fields.DocumentMeta()
+
+ class Meta:
+ type_ = "node--study_site_or_center"
+
+
+class UserSchema(Schema):
+ id = fields.Str(dump_only=True)
+ display_name = fields.Str()
+ drupal_internal__uid = fields.Str()
+ name = fields.Str()
+ mail = fields.Str()
+ field_given_first_name_s_ = fields.Str()
+ field_examples_family_last_name_ = fields.Str()
+ field_study_site_or_center = fields.Relationship(
+ many=True, schema="StudySiteSchema", type_="node--study_site_or_center"
+ )
+
+ class Meta:
+ type_ = "users"
+
+
+# def debug_requests_on():
+# """Switches on logging of the requests module."""
+# HTTPConnection.debuglevel = 1
+
+# logging.basicConfig()
+# logging.getLogger().setLevel(logging.DEBUG)
+# requests_log = logging.getLogger("requests.packages.urllib3")
+# requests_log.setLevel(logging.DEBUG)
+# requests_log.propagate = True
+
+
+TEST_STUDY_SITE_DATA = [
+ StudySiteMockObject(
+ **{
+ "id": "1",
+ "drupal_internal__nid": "1",
+ "title": "SS1",
+ "field_long_name": "S S 1",
+ # "document_meta": {"page": {"offset": 10}},
+ }
+ ),
+ StudySiteMockObject(
+ **{
+ "id": "2",
+ "drupal_internal__nid": "2",
+ "title": "SS2",
+ "field_long_name": "S S 2",
+ # "document_meta": {"page": {"offset": 10}},
+ }
+ ),
+]
+
+TEST_USER_DATA = [
+ UserMockObject(
+ **{
+ "id": "usr1",
+ "display_name": "dnusr1",
+ "drupal_internal__uid": "usr1",
+ "name": "testuser1",
+ "mail": "testuser1@test.com",
+ "field_given_first_name_s_": "test1",
+ "field_examples_family_last_name_": "user1",
+ "field_study_site_or_center": [],
+ }
+ ),
+ # second mock object is deactivated user (no drupal uid)
+ UserMockObject(
+ **{
+ "id": "usr2",
+ "display_name": "dnusr2",
+ "drupal_internal__uid": "",
+ "name": "testuser2",
+ "mail": "testuser2@test.com",
+ "field_given_first_name_s_": "test2",
+ "field_examples_family_last_name_": "user2",
+ "field_study_site_or_center": [],
+ }
+ ),
+]
+
+
+class TestUserDataAudit(TestCase):
+ """General tests of the user audit"""
+
+ def setUp(self):
+ # debug_requests_on()
+ super().setUp()
+ fake_time = time.time()
+ self.token = {
+ "token_type": "Bearer",
+ "access_token": "asdfoiw37850234lkjsdfsdfTEST", # gitleaks:allow
+ "refresh_token": "sldvafkjw34509s8dfsdfTEST", # gitleaks:allow
+ "expires_in": 3600,
+ "expires_at": fake_time + 3600,
+ }
+
+ def add_fake_study_sites_response(self):
+ url_path = f"{settings.DRUPAL_SITE_URL}/{settings.DRUPAL_API_REL_PATH}/node/study_site_or_center/"
+ responses.get(
+ url=url_path,
+ body=json.dumps(StudySiteSchema(many=True).dump(TEST_STUDY_SITE_DATA)),
+ )
+
+ def add_fake_users_response(self):
+ url_path = (
+ f"{settings.DRUPAL_SITE_URL}/{settings.DRUPAL_API_REL_PATH}/user/user/"
+ )
+ TEST_USER_DATA[0].field_study_site_or_center = [TEST_STUDY_SITE_DATA[0]]
+ user_data = UserSchema(
+ include_data=("field_study_site_or_center",), many=True
+ ).dump(TEST_USER_DATA)
+
+ responses.get(
+ url=url_path,
+ body=json.dumps(user_data),
+ )
+
+ def add_fake_token_response(self):
+ token_url = f"{settings.DRUPAL_SITE_URL}/oauth/token"
+ responses.post(url=token_url, body=json.dumps(self.token))
+
+ def get_fake_json_api(self):
+ self.add_fake_token_response()
+ return audit.get_drupal_json_api()
+
+ @responses.activate
+ def test_get_json_api(self):
+ json_api = self.get_fake_json_api()
+ assert (
+ json_api.requests.config.AUTH._client.token["access_token"]
+ == self.token["access_token"]
+ )
+
+ @responses.activate
+ def test_get_study_sites(self):
+ json_api = self.get_fake_json_api()
+ self.add_fake_study_sites_response()
+ study_sites = audit.get_study_sites(json_api=json_api)
+
+ for test_study_site in TEST_STUDY_SITE_DATA:
+
+ assert (
+ test_study_site.field_long_name
+ == study_sites[test_study_site.drupal_internal__nid]["full_name"]
+ )
+ assert (
+ test_study_site.title
+ == study_sites[test_study_site.drupal_internal__nid]["short_name"]
+ )
+ assert (
+ test_study_site.drupal_internal__nid
+ == study_sites[test_study_site.drupal_internal__nid]["node_id"]
+ )
+
+ @responses.activate
+ def test_audit_study_sites_no_update(self):
+ self.get_fake_json_api()
+ self.add_fake_study_sites_response()
+ site_audit = audit.SiteAudit(apply_changes=False)
+ site_audit.run_audit()
+ self.assertFalse(site_audit.ok())
+ self.assertEqual(len(site_audit.errors), 0)
+ self.assertEqual(len(site_audit.needs_action), 2)
+ self.assertEqual(StudySite.objects.all().count(), 0)
+
+ @responses.activate
+ def test_audit_study_sites_with_new_sites(self):
+ self.get_fake_json_api()
+ self.add_fake_study_sites_response()
+ site_audit = audit.SiteAudit(apply_changes=True)
+ site_audit.run_audit()
+ self.assertFalse(site_audit.ok())
+ self.assertEqual(len(site_audit.needs_action), 2)
+ self.assertEqual(StudySite.objects.all().count(), 2)
+
+ assert (
+ StudySite.objects.filter(
+ short_name__in=[
+ TEST_STUDY_SITE_DATA[0].title,
+ TEST_STUDY_SITE_DATA[1].title,
+ ]
+ ).count()
+ == 2
+ )
+ assert len(site_audit.get_needs_action_table().rows) == 2
+
+ @responses.activate
+ def test_audit_study_sites_with_site_update(self):
+ StudySite.objects.create(
+ drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid,
+ short_name="WrongShortName",
+ full_name="WrongTitle",
+ )
+ StudySite.objects.create(
+ drupal_node_id=TEST_STUDY_SITE_DATA[1].drupal_internal__nid,
+ short_name=TEST_STUDY_SITE_DATA[1].title,
+ full_name=TEST_STUDY_SITE_DATA[1].field_long_name,
+ )
+ self.get_fake_json_api()
+ self.add_fake_study_sites_response()
+ site_audit = audit.SiteAudit(apply_changes=True)
+ site_audit.run_audit()
+ self.assertFalse(site_audit.ok())
+ self.assertEqual(len(site_audit.needs_action), 1)
+ self.assertEqual(len(site_audit.verified), 1)
+ self.assertEqual(len(site_audit.errors), 0)
+ self.assertEqual(StudySite.objects.all().count(), 2)
+
+ first_test_ss = StudySite.objects.get(short_name=TEST_STUDY_SITE_DATA[0].title)
+ # did we update the long name
+ assert first_test_ss.full_name == TEST_STUDY_SITE_DATA[0].field_long_name
+ assert first_test_ss.short_name == TEST_STUDY_SITE_DATA[0].title
+
+ @responses.activate
+ def test_audit_study_sites_with_extra_site(self):
+ StudySite.objects.create(
+ drupal_node_id=99, short_name="ExtraSite", full_name="ExtraSiteLong"
+ )
+ self.get_fake_json_api()
+ self.add_fake_study_sites_response()
+ site_audit = audit.SiteAudit(apply_changes=True)
+ site_audit.run_audit()
+ self.assertFalse(site_audit.ok())
+ self.assertEqual(len(site_audit.errors), 1)
+ self.assertEqual(StudySite.objects.all().count(), 3)
+ assert len(site_audit.get_errors_table().rows) == 1
+
+ @responses.activate
+ def test_full_user_audit(self):
+ self.add_fake_token_response()
+ self.add_fake_study_sites_response()
+ self.add_fake_users_response()
+ StudySite.objects.create(
+ drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid,
+ short_name=TEST_STUDY_SITE_DATA[0].title,
+ full_name=TEST_STUDY_SITE_DATA[0].field_long_name,
+ )
+ user_audit = audit.UserAudit(apply_changes=True)
+ user_audit.run_audit()
+
+ self.assertFalse(user_audit.ok())
+ self.assertEqual(len(user_audit.needs_action), 1)
+
+ users = get_user_model().objects.all()
+ assert users.count() == 1
+
+ assert users.first().email == TEST_USER_DATA[0].mail
+ assert users.first().username == TEST_USER_DATA[0].name
+ assert users.first().study_sites.count() == 1
+ assert (
+ users.first().study_sites.first().short_name
+ == TEST_STUDY_SITE_DATA[0].title
+ )
+
+ @responses.activate
+ def test_full_user_audit_check_only(self):
+ self.add_fake_token_response()
+ self.add_fake_study_sites_response()
+ self.add_fake_users_response()
+ StudySite.objects.create(
+ drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid,
+ short_name=TEST_STUDY_SITE_DATA[0].title,
+ full_name=TEST_STUDY_SITE_DATA[0].field_long_name,
+ )
+ user_audit = audit.UserAudit(apply_changes=False)
+ user_audit.run_audit()
+ self.assertFalse(user_audit.ok())
+ self.assertEqual(len(user_audit.needs_action), 1)
+
+ # verify we did not actually create a user
+ users = get_user_model().objects.all()
+ assert users.count() == 0
+
+ @responses.activate
+ def test_user_audit_remove_site_inform(self):
+ self.add_fake_token_response()
+ self.add_fake_study_sites_response()
+ self.add_fake_users_response()
+ ss1 = StudySite.objects.create(
+ drupal_node_id=TEST_STUDY_SITE_DATA[1].drupal_internal__nid,
+ short_name=TEST_STUDY_SITE_DATA[1].title,
+ full_name=TEST_STUDY_SITE_DATA[1].field_long_name,
+ )
+ drupal_fullname = "{} {}".format(
+ TEST_USER_DATA[0].field_given_first_name_s_,
+ TEST_USER_DATA[0].field_examples_family_last_name_,
+ )
+ drupal_username = TEST_USER_DATA[0].name
+ drupal_email = TEST_USER_DATA[0].mail
+ new_user = get_user_model().objects.create(
+ username=drupal_username + "UPDATE",
+ email=drupal_email + "UPDATE",
+ name=drupal_fullname + "UPDATE",
+ )
+ new_user.study_sites.add(ss1)
+ SocialAccount.objects.create(
+ user=new_user,
+ uid=TEST_USER_DATA[0].drupal_internal__uid,
+ provider=CustomProvider.id,
+ )
+ user_audit = audit.UserAudit(apply_changes=False)
+ user_audit.run_audit()
+ self.assertFalse(user_audit.ok())
+ self.assertEqual(len(user_audit.errors), 1)
+
+ new_user.refresh_from_db()
+ # assert we did not remove the site
+ assert ss1 in new_user.study_sites.all()
+
+ @responses.activate
+ def test_user_audit_remove_site_act(self):
+ self.add_fake_token_response()
+ self.add_fake_study_sites_response()
+ self.add_fake_users_response()
+ ss1 = StudySite.objects.create(
+ drupal_node_id=TEST_STUDY_SITE_DATA[1].drupal_internal__nid,
+ short_name=TEST_STUDY_SITE_DATA[1].title,
+ full_name=TEST_STUDY_SITE_DATA[1].field_long_name,
+ )
+ drupal_fullname = "{} {}".format(
+ TEST_USER_DATA[0].field_given_first_name_s_,
+ TEST_USER_DATA[0].field_examples_family_last_name_,
+ )
+ drupal_username = TEST_USER_DATA[0].name
+ drupal_email = TEST_USER_DATA[0].mail
+ new_user = get_user_model().objects.create(
+ username=drupal_username + "UPDATE",
+ email=drupal_email + "UPDATE",
+ name=drupal_fullname + "UPDATE",
+ )
+ new_user.study_sites.add(ss1)
+ SocialAccount.objects.create(
+ user=new_user,
+ uid=TEST_USER_DATA[0].drupal_internal__uid,
+ provider=CustomProvider.id,
+ )
+ with self.settings(DRUPAL_DATA_AUDIT_REMOVE_USER_SITES=True):
+ user_audit = audit.UserAudit(apply_changes=True)
+ user_audit.run_audit()
+ self.assertFalse(user_audit.ok())
+ new_user.refresh_from_db()
+ # assert we did remove the site
+ assert ss1 not in new_user.study_sites.all()
+
+ @responses.activate
+ def test_user_audit_change_user(self):
+ self.add_fake_token_response()
+ self.add_fake_study_sites_response()
+ self.add_fake_users_response()
+ StudySite.objects.create(
+ drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid,
+ short_name=TEST_STUDY_SITE_DATA[0].title,
+ full_name=TEST_STUDY_SITE_DATA[0].field_long_name,
+ )
+ drupal_fullname = "{} {}".format(
+ TEST_USER_DATA[0].field_given_first_name_s_,
+ TEST_USER_DATA[0].field_examples_family_last_name_,
+ )
+ drupal_username = TEST_USER_DATA[0].name
+ drupal_email = TEST_USER_DATA[0].mail
+ new_user = get_user_model().objects.create(
+ username=drupal_username + "UPDATE",
+ email=drupal_email + "UPDATE",
+ name=drupal_fullname + "UPDATE",
+ is_active=False,
+ )
+ SocialAccount.objects.create(
+ user=new_user,
+ uid=TEST_USER_DATA[0].drupal_internal__uid,
+ provider=CustomProvider.id,
+ )
+ user_audit = audit.UserAudit(apply_changes=True)
+ user_audit.run_audit()
+ self.assertFalse(user_audit.ok())
+ new_user.refresh_from_db()
+
+ self.assertEqual(new_user.name, drupal_fullname)
+ self.assertEqual(len(user_audit.needs_action), 1)
+
+ # test user removal
+ @responses.activate
+ def test_user_audit_remove_user_only_inform(self):
+ self.add_fake_token_response()
+ self.add_fake_study_sites_response()
+ self.add_fake_users_response()
+ StudySite.objects.create(
+ drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid,
+ short_name=TEST_STUDY_SITE_DATA[0].title,
+ full_name=TEST_STUDY_SITE_DATA[0].field_long_name,
+ )
+
+ new_user = get_user_model().objects.create(
+ username="username2", email="useremail2", name="user fullname2"
+ )
+ SocialAccount.objects.create(
+ user=new_user,
+ uid=999,
+ provider=CustomProvider.id,
+ )
+ user_audit = audit.UserAudit(apply_changes=True)
+ user_audit.run_audit()
+ self.assertFalse(user_audit.ok())
+
+ new_user.refresh_from_db()
+ self.assertTrue(new_user.is_active)
+
+ # test user removal
+ @responses.activate
+ def test_user_audit_remove_user(self):
+ self.add_fake_token_response()
+ self.add_fake_study_sites_response()
+ self.add_fake_users_response()
+ StudySite.objects.create(
+ drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid,
+ short_name=TEST_STUDY_SITE_DATA[0].title,
+ full_name=TEST_STUDY_SITE_DATA[0].field_long_name,
+ )
+
+ new_user = get_user_model().objects.create(
+ username="username2", email="useremail2", name="user fullname2"
+ )
+ SocialAccount.objects.create(
+ user=new_user,
+ uid=999,
+ provider=CustomProvider.id,
+ )
+ new_anvil_account = Account.objects.create(
+ user=new_user,
+ is_service_account=False,
+ )
+ new_anvil_managed_group = ManagedGroup.objects.create(
+ name="testgroup",
+ email="testgroup@testgroup.org",
+ )
+ GroupAccountMembership.objects.create(
+ group=new_anvil_managed_group,
+ account=new_anvil_account,
+ role=GroupAccountMembership.MEMBER,
+ )
+
+ with self.settings(DRUPAL_DATA_AUDIT_DEACTIVATE_USERS=True):
+ user_audit = audit.UserAudit(apply_changes=True)
+ user_audit.run_audit()
+ self.assertFalse(user_audit.ok())
+ self.assertEqual(len(user_audit.errors), 1)
+ self.assertEqual(user_audit.errors[0].anvil_account, new_anvil_account)
+ self.assertIn(
+ "InactiveAnvilUser", user_audit.get_errors_table().render_to_text()
+ )
+ self.assertEqual(len(user_audit.needs_action), 2)
+ new_user.refresh_from_db()
+ self.assertFalse(new_user.is_active)
+
+ # test user removal
+ @responses.activate
+ def test_user_audit_remove_user_threshold(self):
+ self.add_fake_token_response()
+ self.add_fake_study_sites_response()
+ self.add_fake_users_response()
+ StudySite.objects.create(
+ drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid,
+ short_name=TEST_STUDY_SITE_DATA[0].title,
+ full_name=TEST_STUDY_SITE_DATA[0].field_long_name,
+ )
+
+ SocialAccount.objects.create(
+ user=get_user_model().objects.create(
+ username="username2", email="useremail2", name="user fullname2"
+ ),
+ uid=996,
+ provider=CustomProvider.id,
+ )
+
+ SocialAccount.objects.create(
+ user=get_user_model().objects.create(
+ username="username3", email="useremail3", name="user fullname3"
+ ),
+ uid=997,
+ provider=CustomProvider.id,
+ )
+
+ SocialAccount.objects.create(
+ user=get_user_model().objects.create(
+ username="username4", email="useremail4", name="user fullname4"
+ ),
+ uid=998,
+ provider=CustomProvider.id,
+ )
+ SocialAccount.objects.create(
+ user=get_user_model().objects.create(
+ username="username5", email="useremail5", name="user fullname5"
+ ),
+ uid=999,
+ provider=CustomProvider.id,
+ )
+ with self.settings(DRUPAL_DATA_AUDIT_DEACTIVATE_USERS=True):
+ user_audit = audit.UserAudit(apply_changes=False)
+ user_audit.run_audit()
+ self.assertFalse(user_audit.ok())
+ self.assertEqual(len(user_audit.errors), 4)
+ self.assertEqual(len(user_audit.needs_action), 1)
+ self.assertEqual(user_audit.errors[0].note, "Over Threshold True")
+ # Run again with ignore threshold, should move from error to needs action
+ user_audit = audit.UserAudit(
+ apply_changes=False, ignore_deactivate_threshold=True
+ )
+ user_audit.run_audit()
+ self.assertFalse(user_audit.ok())
+ self.assertEqual(len(user_audit.errors), 0)
+ self.assertEqual(len(user_audit.needs_action), 5)
+
+ @responses.activate
+ def test_sync_drupal_data_command(self):
+ self.add_fake_token_response()
+ self.add_fake_study_sites_response()
+ self.add_fake_users_response()
+ out = StringIO()
+ call_command("sync-drupal-data", stdout=out)
+ self.assertIn(
+ "SiteAudit summary: status ok: False verified: 0 needs_changes: 2",
+ out.getvalue(),
+ )
+
+ @responses.activate
+ def test_sync_drupal_data_command_with_issues(self):
+
+ StudySite.objects.create(
+ drupal_node_id="999999",
+ short_name=TEST_STUDY_SITE_DATA[0].title,
+ full_name=TEST_STUDY_SITE_DATA[0].field_long_name,
+ )
+
+ new_user = get_user_model().objects.create(
+ username="username2", email="useremail2", name="user fullname2"
+ )
+ SocialAccount.objects.create(
+ user=new_user,
+ uid=999,
+ provider=CustomProvider.id,
+ )
+ self.add_fake_token_response()
+ self.add_fake_study_sites_response()
+ self.add_fake_users_response()
+ out = StringIO()
+ call_command("sync-drupal-data", "--email=test@example.com", stdout=out)
+ self.assertIn("SiteAudit summary: status ok: False", out.getvalue())
+ self.assertIn("UserAudit summary: status ok: False", out.getvalue())
+
+ self.assertEqual(len(mail.outbox), 1)
+ email = mail.outbox[0]
+ self.assertEqual(email.to, ["test@example.com"])
+ self.assertEqual(email.subject, "[command:sync-drupal-data] report")
diff --git a/requirements/requirements.in b/requirements/requirements.in
index b855f9cc..b977ac59 100644
--- a/requirements/requirements.in
+++ b/requirements/requirements.in
@@ -44,6 +44,9 @@ requests
# For json schema validation.
jsonschema
+# For interacting with drupal json api
+jsonapi-requests
+
# For tree structures
django-tree-queries
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index 8a0f9bd2..1009369c 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -117,6 +117,8 @@ importlib-resources==6.1.1
# via
# jsonschema
# jsonschema-specifications
+jsonapi-requests==0.7.0
+ # via -r requirements/requirements.in
jsonschema==4.21.1
# via -r requirements/requirements.in
jsonschema-specifications==2023.12.1
@@ -187,6 +189,7 @@ requests==2.31.0
# -r requirements/requirements.in
# django-allauth
# django-anvil-consortium-manager
+ # jsonapi-requests
# requests-oauthlib
requests-oauthlib==1.3.1
# via django-allauth
@@ -205,7 +208,9 @@ sqlparse==0.4.4
tablib==3.6.1
# via -r requirements/requirements.in
tenacity==8.2.3
- # via plotly
+ # via
+ # jsonapi-requests
+ # plotly
tomli==2.0.1
# via
# build
diff --git a/requirements/test-requirements.in b/requirements/test-requirements.in
index df760727..c5e95951 100644
--- a/requirements/test-requirements.in
+++ b/requirements/test-requirements.in
@@ -18,3 +18,5 @@ freezegun # https://github.com/spulec/freezegun
django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin
# Test coverage.
coverage
+# Mock json api data
+marshmallow-jsonapi
diff --git a/requirements/test-requirements.txt b/requirements/test-requirements.txt
index 25c305e9..f58f6830 100644
--- a/requirements/test-requirements.txt
+++ b/requirements/test-requirements.txt
@@ -34,9 +34,14 @@ idna==3.3
# requests
iniconfig==2.0.0
# via pytest
+marshmallow==3.20.1
+ # via marshmallow-jsonapi
+marshmallow-jsonapi==0.24.0
+ # via -r requirements/test-requirements.in
packaging==21.3
# via
# -c requirements/requirements.txt
+ # marshmallow
# pytest
# pytest-sugar
pluggy==1.4.0