From 7cdf830a7f381985a866d320fe6d7eaf31ec32d4 Mon Sep 17 00:00:00 2001 From: Tore Brede <Tore.Brede@uib.no> Date: Tue, 13 Jul 2021 08:51:57 +0200 Subject: [PATCH] GREG-3: Adding more model classes --- greg/migrations/0001_initial.py | 135 +++++++++++++++++++- greg/models.py | 214 ++++++++++++++++++++++++++++++-- greg/tests/test_api_person.py | 6 + greg/tests/test_models.py | 182 +++++++++++++++++++++++++++ 4 files changed, 523 insertions(+), 14 deletions(-) create mode 100644 greg/tests/test_models.py diff --git a/greg/migrations/0001_initial.py b/greg/migrations/0001_initial.py index aa796fd9..4d782f4e 100644 --- a/greg/migrations/0001_initial.py +++ b/greg/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 3.2.5 on 2021-07-09 07:27 +# Generated by Django 3.2.5 on 2021-07-13 06:47 +import datetime import dirtyfields.dirtyfields from django.db import migrations, models import django.db.models.deletion @@ -13,6 +14,27 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='Consent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('type', models.SlugField(max_length=64, unique=True)), + ('consent_name_en', models.CharField(max_length=256)), + ('consent_name_nb', models.CharField(max_length=256)), + ('consent_description_en', models.TextField()), + ('consent_description_nb', models.TextField()), + ('consent_link_en', models.URLField(null=True)), + ('consent_link_nb', models.URLField(null=True)), + ('valid_from', models.DateField(default=datetime.date.today)), + ('user_allowed_to_change', models.BooleanField()), + ], + options={ + 'abstract': False, + }, + bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), + ), migrations.CreateModel( name='Notification', fields=[ @@ -30,6 +52,19 @@ class Migration(migrations.Migration): }, bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), ), + migrations.CreateModel( + name='OrganizationalUnit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('orgreg_id', models.CharField(max_length=256)), + ('name_nb', models.CharField(max_length=256)), + ('name_en', models.CharField(max_length=256)), + ('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='greg.organizationalunit')), + ], + bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), + ), migrations.CreateModel( name='Person', fields=[ @@ -38,6 +73,13 @@ class Migration(migrations.Migration): ('updated', models.DateTimeField(auto_now=True)), ('first_name', models.CharField(max_length=256)), ('last_name', models.CharField(max_length=256)), + ('date_of_birth', models.DateField()), + ('email', models.EmailField(max_length=254)), + ('email_verified_date', models.DateField(blank=True, null=True)), + ('mobile_phone', models.CharField(max_length=15)), + ('mobile_phone_verified_date', models.DateField(blank=True, null=True)), + ('registration_completed_date', models.DateField(blank=True, null=True)), + ('token', models.CharField(blank=True, max_length=32)), ], options={ 'abstract': False, @@ -53,30 +95,119 @@ class Migration(migrations.Migration): ('type', models.SlugField(max_length=64, unique=True)), ('name_nb', models.CharField(max_length=256)), ('name_en', models.CharField(max_length=256)), - ('meta', models.JSONField(blank=True, null=True)), + ('description_nb', models.TextField()), + ('description_en', models.TextField()), + ('default_duration_days', models.IntegerField(null=True)), ], options={ 'abstract': False, }, bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), ), + migrations.CreateModel( + name='Sponsor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('feide_id', models.CharField(max_length=256)), + ], + bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), + ), + migrations.CreateModel( + name='SponsorOrganizationalUnit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('hierarchical_access', models.BooleanField()), + ('organizational_unit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='link_unit', to='greg.organizationalunit')), + ('sponsor', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='link_sponsor', to='greg.sponsor')), + ], + bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), + ), + migrations.AddField( + model_name='sponsor', + name='units', + field=models.ManyToManyField(related_name='sponsor_unit', through='greg.SponsorOrganizationalUnit', to='greg.OrganizationalUnit'), + ), migrations.CreateModel( name='PersonRole', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('contact_person_unit', models.TextField()), + ('comments', models.TextField(blank=True)), + ('available_in_search', models.BooleanField(default=False)), ('person', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='person_roles', to='greg.person')), + ('registered_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sponsor_role', to='greg.sponsor')), ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='person_roles', to='greg.role')), + ('unit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='unit_person_role', to='greg.organizationalunit')), + ], + bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), + ), + migrations.CreateModel( + name='PersonIdentity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('type', models.CharField(choices=[('PASSPORT_NUMBER', 'Passport Number'), ('FEIDE_ID', 'Feide Id')], max_length=15)), + ('source', models.CharField(max_length=256)), + ('value', models.CharField(max_length=256)), + ('verified', models.CharField(blank=True, choices=[('AUTOMATIC', 'Automatic'), ('MANUAL', 'Manual')], max_length=9)), + ('verified_when', models.DateField(blank=True)), + ('person', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='person', to='greg.person')), + ('verified_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sponsor', to='greg.sponsor')), ], options={ 'abstract': False, }, bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), ), + migrations.CreateModel( + name='PersonConsent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('consent_given_at', models.DateField()), + ('consent', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='link_person_consent', to='greg.consent')), + ('person', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='link_person_consent', to='greg.person')), + ], + bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), + ), + migrations.AddField( + model_name='person', + name='consents', + field=models.ManyToManyField(related_name='consent', through='greg.PersonConsent', to='greg.Consent'), + ), migrations.AddField( model_name='person', name='roles', field=models.ManyToManyField(related_name='persons', through='greg.PersonRole', to='greg.Role'), ), + migrations.AddConstraint( + model_name='sponsororganizationalunit', + constraint=models.UniqueConstraint(fields=('sponsor', 'organizational_unit'), name='sponsor_organizational_unit_unique'), + ), + migrations.AddConstraint( + model_name='sponsor', + constraint=models.UniqueConstraint(fields=('feide_id',), name='unique_feide_id'), + ), + migrations.AddConstraint( + model_name='personrole', + constraint=models.UniqueConstraint(fields=('person', 'role'), name='personrole_person_role_unique'), + ), + migrations.AddConstraint( + model_name='personconsent', + constraint=models.UniqueConstraint(fields=('person', 'consent'), name='person_consent_unique'), + ), + migrations.AddConstraint( + model_name='organizationalunit', + constraint=models.UniqueConstraint(fields=('orgreg_id',), name='unique_orgreg_id'), + ), ] diff --git a/greg/models.py b/greg/models.py index 16440ecc..9e3a0304 100644 --- a/greg/models.py +++ b/greg/models.py @@ -1,9 +1,10 @@ +from datetime import date + +from dirtyfields import DirtyFieldsMixin from django.db import models from django.db.models import Lookup from django.db.models.fields import Field -from dirtyfields import DirtyFieldsMixin - @Field.register_lookup class Like(Lookup): @@ -29,11 +30,21 @@ class BaseModel(DirtyFieldsMixin, models.Model): class Person(BaseModel): - """A person.""" + """A person is someone who has requested guest access.""" first_name = models.CharField(max_length=256) last_name = models.CharField(max_length=256) + date_of_birth = models.DateField() + email = models.EmailField() + email_verified_date = models.DateField(null=True, blank=True) + mobile_phone = models.CharField(max_length=15) + mobile_phone_verified_date = models.DateField(null=True, blank=True) + registration_completed_date = models.DateField(null=True, blank=True) + token = models.CharField(max_length=32, blank=True) roles = models.ManyToManyField("Role", through="PersonRole", related_name="persons") + consents = models.ManyToManyField( + "Consent", through="PersonConsent", related_name="consent" + ) def __str__(self): return "{} {} ({})".format(self.first_name, self.last_name, self.pk) @@ -53,19 +64,20 @@ class Role(BaseModel): type = models.SlugField(max_length=64, unique=True) name_nb = models.CharField(max_length=256) name_en = models.CharField(max_length=256) - meta = models.JSONField(null=True, blank=True) + description_nb = models.TextField() + description_en = models.TextField() + default_duration_days = models.IntegerField(null=True) def __str__(self): return str(self.name_nb or self.name_en or self.slug) def __repr__(self): - return "{}(id={!r}, type={!r}, name_nb={!r}, name_en={!r}, meta={!r})".format( + return "{}(id={!r}, type={!r}, name_nb={!r}, name_en={!r})".format( self.__class__.__name__, self.pk, self.type, self.name_nb, self.name_en, - self.meta, ) @@ -78,13 +90,25 @@ class PersonRole(BaseModel): role = models.ForeignKey( "Role", on_delete=models.PROTECT, related_name="person_roles" ) + unit = models.ForeignKey( + "OrganizationalUnit", on_delete=models.PROTECT, related_name="unit_person_role" + ) + start_date = models.DateField() + end_date = models.DateField() + # TODO Is this field needed? + contact_person_unit = models.TextField() + comments = models.TextField(blank=True) + available_in_search = models.BooleanField(default=False) + registered_by = models.ForeignKey( + "Sponsor", on_delete=models.PROTECT, related_name="sponsor_role" + ) - # class Meta: - # constraints = [ - # models.UniqueConstraint( - # fields=["person", "role"], name="personrole_person_role_unique" - # ) - # ] + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["person", "role"], name="personrole_person_role_unique" + ) + ] def __repr__(self): return "{}(id={!r}, person={!r}, role={!r})".format( @@ -111,3 +135,169 @@ class Notification(BaseModel): self.issued_at, self.meta, ) + + +class PersonIdentity(BaseModel): + # TODO: Add more types + class IdentityType(models.TextChoices): + PASSPORT_NUMBER = "PASSPORT_NUMBER" + FEIDE_ID = "FEIDE_ID" + + class Verified(models.TextChoices): + AUTOMATIC = "AUTOMATIC" + MANUAL = "MANUAL" + + person = models.ForeignKey( + "Person", on_delete=models.PROTECT, related_name="person" + ) + type = models.CharField(max_length=15, choices=IdentityType.choices) + source = models.CharField(max_length=256) + value = models.CharField(max_length=256) + verified = models.CharField(max_length=9, choices=Verified.choices, blank=True) + verified_by = models.ForeignKey( + "Sponsor", on_delete=models.PROTECT, related_name="sponsor", null=True + ) + verified_when = models.DateField(blank=True) + + def __repr__(self): + return ( + "{}(id={!r}, type={!r}, source={!r}, value={!r}, verified_by={!r})".format( + self.__class__.__name__, + self.pk, + self.type, + self.source, + self.value, + self.verified_by, + ) + ) + + +class Consent(BaseModel): + """ + Describes some consent, like acknowledging the IT department guidelines, a guest can give. + """ + + type = models.SlugField(max_length=64, unique=True) + consent_name_en = models.CharField(max_length=256) + consent_name_nb = models.CharField(max_length=256) + consent_description_en = models.TextField() + consent_description_nb = models.TextField() + consent_link_en = models.URLField(null=True) + consent_link_nb = models.URLField(null=True) + valid_from = models.DateField(default=date.today) + user_allowed_to_change = models.BooleanField() + + def __repr__(self): + return "{}(id={!r}, type={!r}, consent_name_en={!r}, valid_from={!r}, user_allowed_to_change={!r})".format( + self.__class__.__name__, + self.pk, + self.type, + self.consent_name_en, + self.valid_from, + self.user_allowed_to_change, + ) + + +class PersonConsent(BaseModel): + """ + Links a person and a consent he has given. + """ + + person = models.ForeignKey( + "Person", on_delete=models.PROTECT, related_name="link_person_consent" + ) + consent = models.ForeignKey( + "Consent", on_delete=models.PROTECT, related_name="link_person_consent" + ) + consent_given_at = models.DateField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["person", "consent"], name="person_consent_unique" + ) + ] + + def __repr__(self): + return "{}(id={!r}, person={!r}, consent={!r}, consent_given_at={!r})".format( + self.__class__.__name__, + self.pk, + self.person, + self.consent, + self.consent_given_at, + ) + + +class OrganizationalUnit(BaseModel): + """ + An organizational unit. Units can be organized in a hierarchical manner. + """ + + orgreg_id = models.CharField(max_length=256) + name_nb = models.CharField(max_length=256) + name_en = models.CharField(max_length=256) + parent = models.ForeignKey("self", on_delete=models.PROTECT, null=True) + + def __repr__(self): + return "{}(id={!r}, orgreg_id={!r}, name_en={!r}, parent={!r})".format( + self.__class__.__name__, self.pk, self.orgreg_id, self.name_en, self.parent + ) + + class Meta: + constraints = [ + models.UniqueConstraint(name="unique_orgreg_id", fields=["orgreg_id"]) + ] + + +class Sponsor(BaseModel): + """ + A sponsor is someone who is allowed, with some restrictions, to send out invitations to guests and to verify their identity. + """ + + feide_id = models.CharField(max_length=256) + units = models.ManyToManyField( + "OrganizationalUnit", + through="SponsorOrganizationalUnit", + related_name="sponsor_unit", + ) + + def __repr__(self): + return "{}(id={!r}, feide_id={!r})".format( + self.__class__.__name__, self.pk, self.feide_id + ) + + class Meta: + constraints = [ + models.UniqueConstraint(name="unique_feide_id", fields=["feide_id"]) + ] + + +class SponsorOrganizationalUnit(BaseModel): + """ + A link between a sponsor and an organizational unit. + """ + + sponsor = models.ForeignKey( + "Sponsor", on_delete=models.PROTECT, related_name="link_sponsor" + ) + organizational_unit = models.ForeignKey( + "OrganizationalUnit", on_delete=models.PROTECT, related_name="link_unit" + ) + hierarchical_access = models.BooleanField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["sponsor", "organizational_unit"], + name="sponsor_organizational_unit_unique", + ) + ] + + def __repr__(self): + return "{}(id={!r}, sponsor={!r}, organizational_unit={!r}, hierarchical_access={!r})".format( + self.__class__.__name__, + self.pk, + self.sponsor, + self.organizational_unit, + self.hierarchical_access, + ) diff --git a/greg/tests/test_api_person.py b/greg/tests/test_api_person.py index d4480409..b504534d 100644 --- a/greg/tests/test_api_person.py +++ b/greg/tests/test_api_person.py @@ -28,10 +28,16 @@ class PersonTestData: self.person_foo_data = dict( first_name="Foo", last_name="Foo", + date_of_birth="2000-01-27", + email="test@example.org", + mobile_phone="123456788", ) self.person_bar_data = dict( first_name="Bar", last_name="Bar", + date_of_birth="2000-07-01", + email="test2@example.org", + mobile_phone="123456789", ) self.person_foo = Person.objects.create(**self.person_foo_data) self.person_bar = Person.objects.create(**self.person_bar_data) diff --git a/greg/tests/test_models.py b/greg/tests/test_models.py new file mode 100644 index 00000000..0c587924 --- /dev/null +++ b/greg/tests/test_models.py @@ -0,0 +1,182 @@ +from django.db.models import ProtectedError +from django.test import TestCase + +from greg.models import ( + Person, + Role, + PersonRole, + OrganizationalUnit, + Sponsor, + SponsorOrganizationalUnit, + Consent, +) + + +class PersonModelTests(TestCase): + test_person = dict( + first_name="Test", + last_name="Tester", + date_of_birth="2000-01-27", + email="test@example.org", + mobile_phone="123456789", + ) + + def test_add_multiple_roles_to_person(self): + role1 = Role.objects.create(type="role1", name_en="Role 1") + role2 = Role.objects.create(type="role2", name_en="Role 2") + + unit = OrganizationalUnit.objects.create(orgreg_id="12345", name_en="Test unit") + sponsor = Sponsor.objects.create(feide_id="test@uio.no") + sponsor2 = Sponsor.objects.create(feide_id="test2@uio.no") + + person = Person.objects.create(**self.test_person) + + # Add two roles to the person and check that they appear when listing the roles for the person + PersonRole.objects.create( + person=person, + role=role1, + unit=unit, + start_date="2020-03-05", + end_date="2020-06-10", + contact_person_unit="Contact Person", + available_in_search=True, + registered_by=sponsor, + ) + + PersonRole.objects.create( + person=person, + role=role2, + unit=unit, + start_date="2021-03-05", + end_date="2021-06-10", + contact_person_unit="Contact Person", + available_in_search=True, + registered_by=sponsor2, + ) + + person_roles = person.roles.all() + + self.assertEqual(2, len(person_roles)) + self.assertIn(role1, person_roles) + self.assertIn(role2, person_roles) + + def test_person_not_allowed_deleted_when_roles_are_present(self): + person = Person.objects.create(**self.test_person) + role = Role.objects.create(type="role1", name_en="Role 1") + unit = OrganizationalUnit.objects.create(orgreg_id="12345", name_en="Test unit") + sponsor = Sponsor.objects.create(feide_id="test@uio.no") + + PersonRole.objects.create( + person=person, + role=role, + unit=unit, + start_date="2020-03-05", + end_date="2020-06-10", + contact_person_unit="Contact Person", + available_in_search=True, + registered_by=sponsor, + ) + + # It is not clear what cleanup needs to be done when a person is going to be + # removed, so for now is prohibited to delete a person if there is data + # attached to him in other tables + self.assertRaises(ProtectedError, person.delete) + + +class SponsorModelTests(TestCase): + def test_add_sponsor_to_multiple_units(self): + sponsor = Sponsor.objects.create(feide_id="test@uio.no") + + unit1 = OrganizationalUnit.objects.create( + orgreg_id="12345", name_en="Test unit" + ) + unit2 = OrganizationalUnit.objects.create( + orgreg_id="123456", name_en="Test unit2" + ) + + SponsorOrganizationalUnit.objects.create( + sponsor=sponsor, organizational_unit=unit1, hierarchical_access=False + ) + SponsorOrganizationalUnit.objects.create( + sponsor=sponsor, organizational_unit=unit2, hierarchical_access=False + ) + + sponsor_units = sponsor.units.all() + + self.assertEqual(2, len(sponsor_units)) + self.assertIn(unit1, sponsor_units) + self.assertIn(unit2, sponsor_units) + + def test_add_multiple_sponsors_to_unit(self): + sponsor1 = Sponsor.objects.create(feide_id="test@uio.no") + sponsor2 = Sponsor.objects.create(feide_id="test2@uio.no") + + unit = OrganizationalUnit.objects.create(orgreg_id="12345", name_en="Test unit") + unit2 = OrganizationalUnit.objects.create( + orgreg_id="123456", name_en="Test unit2" + ) + + SponsorOrganizationalUnit.objects.create( + sponsor=sponsor1, organizational_unit=unit, hierarchical_access=False + ) + SponsorOrganizationalUnit.objects.create( + sponsor=sponsor2, organizational_unit=unit, hierarchical_access=True + ) + + sponsor1_units = sponsor1.units.all() + self.assertEqual(1, len(sponsor1_units)) + self.assertIn(unit, sponsor1_units) + + sponsor2_units = sponsor2.units.all() + self.assertEqual(1, len(sponsor2_units)) + self.assertIn(unit, sponsor2_units) + + sponsors_for_unit = Sponsor.objects.filter(units=unit.id) + self.assertEqual(2, len(sponsors_for_unit)) + self.assertIn(sponsor1, sponsors_for_unit) + self.assertIn(sponsor2, sponsors_for_unit) + + sponsors_for_unit2 = Sponsor.objects.filter(units=unit2.id) + self.assertEqual(0, len(sponsors_for_unit2)) + + +class ConsentModelTest(TestCase): + def test_add_consent_to_person(self): + person = Person.objects.create( + first_name="Test", + last_name="Tester", + date_of_birth="2000-01-27", + email="test@example.org", + mobile_phone="123456789", + ) + + consent = Consent.objects.create( + type="it_guidelines", + consent_name_en="IT Guidelines", + consent_name_nb="IT Regelverk", + consent_description_en="IT Guidelines description", + consent_description_nb="IT Regelverk beskrivelse", + consent_link_en="https://example.org/it_guidelines", + user_allowed_to_change=False, + ) + + person.consents.add( + consent, through_defaults={"consent_given_at": "2021-06-20"} + ) + + +class OrganizationalUnitTest(TestCase): + def test_set_parent_for_unit(self): + parent = OrganizationalUnit.objects.create( + orgreg_id="12345", name_en="Parent unit", name_nb="Foreldreseksjon" + ) + child = OrganizationalUnit.objects.create( + orgreg_id="123456", + name_en="Child unit", + name_nb="Barneseksjon", + parent=parent, + ) + + query_result = OrganizationalUnit.objects.filter(parent__id=parent.id) + self.assertEqual(1, len(query_result)) + self.assertIn(child, query_result) -- GitLab