diff --git a/greg/management/commands/import_sponsors_from_cerebrum.py b/greg/management/commands/import_sponsors_from_cerebrum.py new file mode 100644 index 0000000000000000000000000000000000000000..7139e11579117429426f0ca4347251f6ed9ffbe0 --- /dev/null +++ b/greg/management/commands/import_sponsors_from_cerebrum.py @@ -0,0 +1,199 @@ +""" +Fetch sponsors for all units in GREG. + +Uses the members of the adm-leder-<legacy_stedkode> groups to +populate Sponsors and SponsorOrganizationalUnits. + +This script does only remove the SponsorOrganizationalUnit. +The Sponsor objects are kept, even with no units +""" + +from typing import Optional, Tuple +import cerebrum_client + +import structlog + +from cerebrum_client import CerebrumClient +from django.conf import settings +from django.core.management.base import BaseCommand + +from greg.models import OrganizationalUnit, Sponsor, SponsorOrganizationalUnit + +logger = structlog.getLogger(__name__) + + +class Command(BaseCommand): + help = __doc__ + + CEREBRUM_SOURCE = "cerebrum" + CEREBRUM_FEIDE_INST = "uio.no" + CEREBRUM_NAME_SOURCE_PRIORITY = ["Cached", "Override", "DFO_SAP", "FS", "Manual"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.client = CerebrumClient(**settings.CEREBRUM_CLIENT) + + def _has_active_dfo_aff(self, person_id: str): + """Check that a person has a valid employee affiliation from DFØ.""" + + dfo_employee_affs = [ + x + for x in self.client.get_person_affiliations(person_id) + if x.source_system == "DFO_SAP" and x.affiliation == "ANSATT" + ] + + return len(dfo_employee_affs) > 0 + + def _upsert_sponsor_unit_link(self, sponsor: Sponsor, unit: OrganizationalUnit): + """Ensure a link between sponsor and unit.""" + sunit, created = SponsorOrganizationalUnit.objects.get_or_create( + sponsor=sponsor, + organizational_unit=unit, + source=self.CEREBRUM_SOURCE, + automatic=True, + ) + if created: + logger.info("sponsor_ou_link_create", sponsor=sponsor.id, sunit=sunit.id) + else: + logger.info("sponsor_ou_link_found", sponsor=sponsor.id, sunit=sunit.id) + sunit.hierarchical_access = settings.CEREBRUM_HIERARCHICAL_ACCESS + sunit.save() + + return SponsorOrganizationalUnit.objects.get(id=sunit.id) + + def _remove_sponsor_unit_link(self, sunit: SponsorOrganizationalUnit): + logger.info("sponsor_ou_deleted", sunit=sunit.id) + sunit.delete() + + def _get_person_name( + self, person: cerebrum_client.models.Person + ) -> Tuple[Optional[str], Optional[str]]: + """Get a persons chosen name.""" + first_names = {x.source_system: x for x in person.names if x.variant == "FIRST"} + last_names = {x.source_system: x for x in person.names if x.variant == "LAST"} + + for source_system in self.CEREBRUM_NAME_SOURCE_PRIORITY: + if source_system in first_names and source_system in last_names: + return first_names[source_system].name, last_names[source_system].name + + return None, None + + def _get_feide_id(self, person_id: str) -> Optional[str]: + """Infer the feide id from the primary user.""" + primary_uname = self.client.get_person_primary_account_name(person_id) + + if primary_uname: + return f"{primary_uname}@{self.CEREBRUM_FEIDE_INST}" + return None + + def _upsert_sponsor_from_cerebrum( + self, person_id: str, unit: OrganizationalUnit + ) -> Optional[Sponsor]: + """Insert or update a sponsor from Cerebum data.""" + person = self.client.get_person(person_id) + if not person: + logger.warning("cerebrum_person_missing", cerebrum_person_id=person_id) + return None + + if not self._has_active_dfo_aff(person_id): + logger.warning("cerebrum_not_an_employee", cerebrum_person_id=person_id) + return None + + feide_id = self._get_feide_id(person_id) + if not feide_id: + logger.warning("cerebrum_no_primary_account", cerebrum_person_id=person_id) + return None + + first_name, last_name = self._get_person_name(person) + if not first_name or not last_name: + logger.warning("cerebrum_no_valid_name", cerebrum_person_id=person_id) + return None + + try: + sponsor = Sponsor.objects.get(feide_id=feide_id) + sponsor.first_name = first_name + sponsor.last_name = last_name + sponsor.save() + logger.info( + "sponsor_updated", sponsor=sponsor.id, cerebrum_person_id=person_id + ) + + except Sponsor.DoesNotExist: + sponsor = Sponsor.objects.create( + first_name=first_name, + last_name=last_name, + feide_id=feide_id, + ) + logger.info( + "sponsor_created", sponsor=sponsor.id, cerebrum_person_id=person_id + ) + return Sponsor.objects.get(id=sponsor.id) + + def handle(self, *args, **options): + """Import of Sponsors from Cerebrum.""" + active_units = OrganizationalUnit.objects.filter( + active=True, + deleted=False, + ) + + logger.info("import_start", nr_of_units=len(active_units)) + for unit in active_units: + logger.bind(unit=unit.id) + sko = unit.identifiers.filter(name="legacy_stedkode").first() + if not sko: + logger.warning("orgreg_unit_missing_legacy_stedkode") + continue + logger.bind(legacy_stedkode=sko.value) + + current_sponsors = unit.link_unit.filter( + automatic=True, source=self.CEREBRUM_SOURCE + ).all() + + group_name = f"adm-leder-{sko.value}" + group = self.client.get_group(group_name) + if not group: + # No group in cererbum, remove sponsors. + logger.info( + "cerebrum_group_not_found", + unit_id=unit.id, + cerebrum_group=group_name, + ) + for sponsor in current_sponsors: + self._remove_sponsor_unit_link(sponsor) + continue + + if group.expire_date: + # Group is expired, remove sponsors + logger.info( + "cerebrum_group_expired", + unit_id=unit.id, + cerebrum_group=group_name, + ) + for sponsor in current_sponsors: + self._remove_sponsor_unit_link(sponsor) + continue + + group_members = list(self.client.list_group_members(group_name)) + if not group_members: + # No members in group, remove sponsors + logger.info( + "cerebrum_group_empty", unit_id=unit.id, cerebrum_group=group_name + ) + for sponsor in current_sponsors: + self._remove_sponsor_unit_link(sponsor) + continue + + cerebrum_sponsors = set() + for member in group_members: + if member.type == "person": + sponsor = self._upsert_sponsor_from_cerebrum(member.id, unit) + if sponsor: + sponsor_link = self._upsert_sponsor_unit_link( + sponsor=sponsor, unit=unit + ) + cerebrum_sponsors.add(sponsor_link) + + for sponsor in set(current_sponsors) - cerebrum_sponsors: + self._remove_sponsor_unit_link(sponsor) + + logger.info("import_end") diff --git a/greg/migrations/0014_add_sponsor_ou_source_data.py b/greg/migrations/0014_add_sponsor_ou_source_data.py new file mode 100644 index 0000000000000000000000000000000000000000..734591e8d36fa835a19f648cbecbb7f1059ff4d4 --- /dev/null +++ b/greg/migrations/0014_add_sponsor_ou_source_data.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.9 on 2021-11-11 14:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('greg', '0013_delete_scheduletask'), + ] + + operations = [ + migrations.AddField( + model_name='sponsororganizationalunit', + name='automatic', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='sponsororganizationalunit', + name='source', + field=models.CharField(blank=True, max_length=256), + ), + ] diff --git a/greg/models.py b/greg/models.py index 168c69d37616bc611e64926fcbf63c741f20eb73..90d54ca027e484855234163dacd07470e4d285d1 100644 --- a/greg/models.py +++ b/greg/models.py @@ -458,6 +458,8 @@ class SponsorOrganizationalUnit(BaseModel): "OrganizationalUnit", on_delete=models.PROTECT, related_name="link_unit" ) hierarchical_access = models.BooleanField() + automatic = models.BooleanField(default=False) + source = models.CharField(max_length=256, blank=True) class Meta: constraints = [ diff --git a/gregsite/settings/dev.py b/gregsite/settings/dev.py index 5120e02bafbd65944f622b2e137410fb0b27b672..e989299f3e37394bcb514220b71ec4a9ba6d2d1a 100644 --- a/gregsite/settings/dev.py +++ b/gregsite/settings/dev.py @@ -17,6 +17,12 @@ ORGREG_CLIENT = { "headers": {"X-Gravitee-Api-Key": "bar"}, } +CEREBRUM_CLIENT = { + "url": "https://example.com/fake/", + "headers": {"X-Gravitee-Api-Key": "bar"}, +} +CEREBRUM_HIERARCHICAL_ACCESS = True + Q_CLUSTER = { "name": "greg", "workers": 4, diff --git a/mypy.ini b/mypy.ini index 67b5755d1536d9ff082523fd6e614ed10ab59839..971a70a5197920f3f192089742afbf4f9a15e0c7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -14,6 +14,9 @@ django_settings_module = gregsite.settings.dev # Ignore problems in auto generated modules. ignore_errors = True +[mypy-cerebrum_client.*] +ignore_missing_imports = True + [mypy-dirtyfields] ignore_missing_imports = True diff --git a/poetry.lock b/poetry.lock index 84dfd0d43707f484dd0d7cda23a9436dce94e3b1..2c491b9f065beb6b15d7b64b570a041e97d5b0c0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -119,6 +119,25 @@ jinxed = {version = ">=1.1.0", markers = "platform_system == \"Windows\""} six = ">=1.9.0" wcwidth = ">=0.1.4" +[[package]] +name = "cerebrum-client" +version = "1.9.2" +description = "Client for the Cerebrum REST API" +category = "main" +optional = false +python-versions = "*" +develop = false + +[package.dependencies] +pydantic = "*" +requests = "*" + +[package.source] +type = "git" +url = "https://git.app.uib.no/it-bott-integrasjoner/cerebrum-client.git" +reference = "v1.9.2" +resolved_reference = "95a183956ddd164f2a9fa86ed219ef640e72b7be" + [[package]] name = "certifi" version = "2021.10.8" @@ -1099,14 +1118,14 @@ test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.1 [[package]] name = "rope" -version = "0.21.0" +version = "0.21.1" description = "a python refactoring library..." category = "dev" optional = false python-versions = "*" [package.extras] -dev = ["pytest", "pytest-timeout"] +dev = ["build", "pytest", "pytest-timeout"] [[package]] name = "sentry-sdk" @@ -1284,7 +1303,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "45574277e3b663b9eb9b18a24ef0996878944461d137b084199690e9683e32f7" +content-hash = "9887d688cf3c515273b5ccbc7313ba2795d6fbf6e6828091537761c376dbee87" [metadata.files] ansicon = [ @@ -1327,6 +1346,7 @@ blessed = [ {file = "blessed-1.19.0-py2.py3-none-any.whl", hash = "sha256:1f2d462631b2b6d2d4c3c65b54ef79ad87a6ca2dd55255df2f8d739fcc8a1ddb"}, {file = "blessed-1.19.0.tar.gz", hash = "sha256:4db0f94e5761aea330b528e84a250027ffe996b5a94bf03e502600c9a5ad7a61"}, ] +cerebrum-client = [] certifi = [ {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, @@ -2012,7 +2032,8 @@ requests-mock = [ {file = "requests_mock-1.9.3-py2.py3-none-any.whl", hash = "sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970"}, ] rope = [ - {file = "rope-0.21.0.tar.gz", hash = "sha256:366789e069a267296889b2ee7631f9278173b5e7d468f2ea08abe26069a52aef"}, + {file = "rope-0.21.1-py3-none-any.whl", hash = "sha256:7fa433eb946bf806a419a3da354cd9b56d565cadf7159952c8e71c72d4b1a8ec"}, + {file = "rope-0.21.1.tar.gz", hash = "sha256:4fe61ea25ca64f1819be57fbb44ee07ca98b3dce08a0ceaaf3c6d6166b603f7f"}, ] sentry-sdk = [ {file = "sentry-sdk-1.4.3.tar.gz", hash = "sha256:b9844751e40710e84a457c5bc29b21c383ccb2b63d76eeaad72f7f1c808c8828"}, diff --git a/pyproject.toml b/pyproject.toml index 8759d5092c85427677c13f4bbf5dae3cf1fdfcb0..dd8482696de3ff89475606814de131dcb169ef89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ django-q = "^1.3.9" django-structlog = "^2.1.3" structlog = "^21.2.0" phonenumbers = "^8.12.35" +cerebrum-client = {git = "https://git.app.uib.no/it-bott-integrasjoner/cerebrum-client.git", rev = "v1.9.2"} [tool.poetry.dev-dependencies] Faker = "*"