├── claims ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0005_alter_valueclaim_value.py │ ├── 0009_claimtype_value_template.py │ ├── 0006_alter_claimtype_value_schema.py │ ├── 0008_claimtype_code_name.py │ ├── 0007_auto_20211204_1230.py │ ├── 0002_auto_20211203_1338.py │ ├── 0003_auto_20211203_2007.py │ ├── 0004_auto_20211203_2032.py │ └── 0001_initial.py ├── views.py ├── apps.py ├── forms.py ├── permissions.py ├── admin.py ├── models.py ├── tests.py ├── schema.py └── services.py ├── oauth ├── __init__.py ├── tests │ ├── __init__.py │ ├── schema │ │ └── __init__.py │ └── services │ │ ├── __init__.py │ │ └── test_user_profile_service.py ├── migrations │ ├── __init__.py │ ├── 0004_alter_userprofile_profile_picture.py │ ├── 0002_auto_20210206_1222.py │ ├── 0003_auto_20211208_1559.py │ └── 0001_initial.py ├── admin.py ├── signals.py ├── apps.py ├── forms.py ├── models.py ├── urls.py ├── oauth_backend.py ├── services.py ├── views.py └── schema.py ├── person ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0007_personposition_plural_name.py │ ├── 0004_alter_person_position.py │ ├── 0006_auto_20220327_0940.py │ ├── 0005_alter_person_position.py │ ├── 0002_auto_20211212_1532.py │ ├── 0001_initial.py │ └── 0003_personposition_positionabbreviation.py ├── tests.py ├── views.py ├── apps.py ├── admin.py ├── forms.py ├── permissions.py ├── models.py ├── services.py └── schema.py ├── orgcharts ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0003_alter_orgchart_unique_together.py │ ├── 0002_alter_orgchart_created_at.py │ ├── 0007_orgcharterror_status.py │ ├── 0005_alter_orgchart_status.py │ ├── 0004_auto_20211212_1532.py │ ├── 0006_orgcharterror.py │ └── 0001_initial.py ├── tests.py ├── views.py ├── apps.py ├── admin.py ├── management │ └── commands │ │ ├── update_orgcharts.py │ │ └── preprocess_orgcharts.py ├── forms.py ├── permissions.py ├── models.py ├── signals.py ├── schema.py └── services.py ├── settings ├── __init__.py ├── apps.py ├── templates │ ├── registration │ │ ├── logout.html │ │ └── login.html │ ├── auth_base.html │ ├── oauth │ │ ├── authorization.html │ │ └── auth_base.html │ └── account.html ├── admin.py ├── default_groups.py ├── asgi.py ├── wsgi.py ├── configs │ ├── dev.py.example │ ├── production.py │ ├── test.py │ └── base.py ├── search.py ├── schema.py ├── views.py ├── urls.py └── static │ └── css │ └── auth.css ├── organisation ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0005_organisationaddress_phone_prefix.py │ ├── 0007_alter_organisationaddress_street.py │ ├── 0003_organisationentity_short_name.py │ ├── 0008_alter_organisationaddress_postal_code.py │ ├── 0009_alter_organisationaddress_postal_code.py │ ├── 0006_alter_organisationaddress_phone_prefix.py │ ├── 0002_alter_organisationentity_parent.py │ ├── 0001_initial.py │ └── 0004_auto_20211218_0113.py ├── tests.py ├── views.py ├── apps.py ├── admin.py ├── forms.py ├── models.py ├── documents.py ├── permissions.py ├── schema.py └── services.py ├── .platform └── nginx │ └── conf.d │ ├── body_size.conf │ └── timeouts.conf ├── .DS_Store ├── .pre-commit-config.yaml ├── .ebextensions ├── 00-os-packages.config ├── 01-additional-packages.config └── 03-container-commands.config ├── .github └── workflows │ ├── black.yml │ ├── django.yml │ ├── deploy.yml │ └── codeql-analysis.yml ├── README.md ├── manage.py ├── pyproject.toml └── .gitignore /claims/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oauth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /person/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oauth/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /orgcharts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oauth/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /organisation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /claims/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oauth/tests/schema/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oauth/tests/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /orgcharts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /person/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /organisation/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.platform/nginx/conf.d/body_size.conf: -------------------------------------------------------------------------------- 1 | client_max_body_size 200M; -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bundesAPI/strukturen/HEAD/.DS_Store -------------------------------------------------------------------------------- /person/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /claims/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /organisation/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /orgcharts/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /orgcharts/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /person/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /organisation/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /oauth/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from oauth.models import UserProfile 4 | 5 | admin.site.register(UserProfile) 6 | -------------------------------------------------------------------------------- /settings/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SettingsConfig(AppConfig): 5 | name = "settings" 6 | verbose_name = "Strukturen" 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | 2 | repos: 3 | - repo: https://github.com/ambv/black 4 | rev: stable 5 | hooks: 6 | - id: black 7 | language_version: python3.9 8 | -------------------------------------------------------------------------------- /.ebextensions/00-os-packages.config: -------------------------------------------------------------------------------- 1 | packages: 2 | yum: 3 | git: [] 4 | postgresql-devel: [] 5 | automake: [] 6 | gcc: [] 7 | gcc-c++: [] 8 | libcurl-devel: [] -------------------------------------------------------------------------------- /claims/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ClaimsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "claims" 7 | -------------------------------------------------------------------------------- /person/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PersonConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "person" 7 | -------------------------------------------------------------------------------- /oauth/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.db.models.signals import post_save 3 | from django.conf import settings 4 | 5 | from oauth.models import UserProfile 6 | -------------------------------------------------------------------------------- /organisation/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OrganisationConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "organisation" 7 | -------------------------------------------------------------------------------- /.platform/nginx/conf.d/timeouts.conf: -------------------------------------------------------------------------------- 1 | keepalive_timeout 600; 2 | proxy_connect_timeout 600; 3 | proxy_send_timeout 600; 4 | proxy_read_timeout 600; 5 | send_timeout 600; 6 | fastcgi_send_timeout 600; 7 | fastcgi_read_timeout 600; -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: psf/black@stable 11 | -------------------------------------------------------------------------------- /orgcharts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OrgchartsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "orgcharts" 7 | 8 | def ready(self): 9 | from . import signals 10 | -------------------------------------------------------------------------------- /oauth/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OauthConfig(AppConfig): 5 | name = "oauth" 6 | 7 | def register_signals(self): 8 | from . import signals 9 | 10 | def ready(self): 11 | self.register_signals() 12 | -------------------------------------------------------------------------------- /settings/templates/registration/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "auth_base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block form_content %} 6 |
7 |

{% trans "Logged out" %}

8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /settings/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import AdminSite 2 | from django.utils.translation import ugettext_lazy 3 | from django.contrib import admin 4 | 5 | admin.site.site_header = "Strukturen Admin" 6 | admin.site.site_title = "Strukturen Admin Portal" 7 | admin.site.index_title = "Welcome to Strukturen Admin Portal" 8 | -------------------------------------------------------------------------------- /settings/default_groups.py: -------------------------------------------------------------------------------- 1 | from serious_django_permissions.groups import Group 2 | 3 | from person.permissions import CanCreatePersonPermission, CanUpdatePersonPermission 4 | 5 | 6 | class AdministrativeStaffGroup(Group): 7 | permissions = [ 8 | CanCreatePersonPermission, 9 | CanUpdatePersonPermission, 10 | ] 11 | -------------------------------------------------------------------------------- /.ebextensions/01-additional-packages.config: -------------------------------------------------------------------------------- 1 | commands: 2 | 01-setup-yum: 3 | command: sudo yum-config-manager --save --setopt=amzn2-core.skip_if_unavailable=true 4 | 04-remove-yum-cache: 5 | command: sudo rm -rf /var/cache/yum 6 | 05-recreate-yum-cache: 7 | command: sudo yum makecache 8 | 06-install-gpp: 9 | command: sudo yum install gcc-c++ -y -------------------------------------------------------------------------------- /organisation/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from organisation.models import OrganisationAddress 4 | from orgcharts.models import OrgChartURL, OrgChart, OrgChartError 5 | 6 | from reversion.admin import VersionAdmin 7 | 8 | 9 | @admin.register(OrganisationAddress) 10 | class OrganisationAddressAdmin(VersionAdmin): 11 | pass 12 | -------------------------------------------------------------------------------- /person/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from reversion.admin import VersionAdmin 3 | 4 | from .models import PersonPosition, PositionAbbreviation 5 | 6 | 7 | class PositionAbbreviationInline(admin.TabularInline): 8 | model = PositionAbbreviation 9 | 10 | 11 | @admin.register(PersonPosition) 12 | class PersonPositionAdmin(VersionAdmin): 13 | inlines = [ 14 | PositionAbbreviationInline, 15 | ] 16 | -------------------------------------------------------------------------------- /person/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from person.models import Person 4 | 5 | 6 | class UpdatePersonForm(forms.ModelForm): 7 | class Meta: 8 | model = Person 9 | fields = [ 10 | "position", 11 | "name", 12 | ] 13 | 14 | 15 | class CreatePersonForm(forms.ModelForm): 16 | class Meta: 17 | model = Person 18 | fields = [ 19 | "name", 20 | "position", 21 | ] 22 | -------------------------------------------------------------------------------- /settings/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for settings project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.configs.production") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /settings/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for settings project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.configs.production") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /claims/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from claims.models import ValueClaim 4 | from organisation.models import OrganisationEntity 5 | 6 | 7 | class UpdateValueClaimForm(forms.ModelForm): 8 | class Meta: 9 | model = ValueClaim 10 | fields = [ 11 | "value", 12 | ] 13 | 14 | 15 | class CreateValueClaimForm(forms.ModelForm): 16 | class Meta: 17 | model = ValueClaim 18 | fields = [ 19 | "value", 20 | ] 21 | -------------------------------------------------------------------------------- /orgcharts/migrations/0003_alter_orgchart_unique_together.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-08 16:56 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("orgcharts", "0002_alter_orgchart_created_at"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterUniqueTogether( 14 | name="orgchart", 15 | unique_together={("org_chart_url", "document_hash")}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /claims/migrations/0005_alter_valueclaim_value.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-03 20:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("claims", "0004_auto_20211203_2032"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="valueclaim", 15 | name="value", 16 | field=models.JSONField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /orgcharts/migrations/0002_alter_orgchart_created_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-08 16:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("orgcharts", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="orgchart", 15 | name="created_at", 16 | field=models.DateTimeField(auto_now_add=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /settings/configs/dev.py.example: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from os import environ 3 | import os 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.sqlite3', 8 | 'NAME': BASE_DIR / 'db.sqlite3', 9 | } 10 | } 11 | 12 | JWT_PRIVATE_KEY_STRUKTUREN = """ 13 | """ 14 | 15 | 16 | JWT_PUBLIC_KEY_STRUKTUREN = """ 17 | """ 18 | 19 | 20 | # SECURITY WARNING: keep the secret key used in production secret! 21 | SECRET_KEY = "" 22 | 23 | ML_BACKEND_BASE_URL = "http://127.0.0.1:8090" 24 | -------------------------------------------------------------------------------- /oauth/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from oauth.models import UserProfile 4 | 5 | 6 | class UpdateUserProfileForm(forms.ModelForm): 7 | class Meta: 8 | model = UserProfile 9 | fields = [ 10 | "language", 11 | "profile_setup_done", 12 | ] 13 | 14 | 15 | class CreateUserProfileForm(forms.ModelForm): 16 | class Meta: 17 | model = UserProfile 18 | fields = [ 19 | "language", 20 | "profile_setup_done", 21 | ] 22 | -------------------------------------------------------------------------------- /claims/migrations/0009_claimtype_value_template.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-27 11:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("claims", "0008_claimtype_code_name"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="claimtype", 15 | name="value_template", 16 | field=models.TextField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /claims/migrations/0006_alter_claimtype_value_schema.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-03 20:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("claims", "0005_alter_valueclaim_value"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="claimtype", 15 | name="value_schema", 16 | field=models.JSONField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /claims/migrations/0008_claimtype_code_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-12 15:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("claims", "0007_auto_20211204_1230"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="claimtype", 15 | name="code_name", 16 | field=models.CharField(blank=True, max_length=255, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /person/migrations/0007_personposition_plural_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-27 09:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("person", "0006_auto_20220327_0940"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="personposition", 15 | name="plural_name", 16 | field=models.CharField(blank=True, max_length=255, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /oauth/migrations/0004_alter_userprofile_profile_picture.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-12 16:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("oauth", "0003_auto_20211208_1559"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="userprofile", 15 | name="profile_picture", 16 | field=models.ImageField(blank=True, null=True, upload_to=""), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /orgcharts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from orgcharts.models import OrgChartURL, OrgChart, OrgChartError 4 | 5 | from reversion.admin import VersionAdmin 6 | 7 | 8 | @admin.register(OrgChartURL) 9 | class OrgChartURLAdmin(VersionAdmin): 10 | autocomplete_fields = ["organisation_entity"] 11 | search_fields = ["organisation_entity"] 12 | 13 | 14 | @admin.register(OrgChart) 15 | class OrgChartAdmin(VersionAdmin): 16 | pass 17 | 18 | 19 | @admin.register(OrgChartError) 20 | class OrgChartErrorAdmin(VersionAdmin): 21 | pass 22 | -------------------------------------------------------------------------------- /organisation/migrations/0005_organisationaddress_phone_prefix.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2022-01-08 02:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("organisation", "0004_auto_20211218_0113"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="organisationaddress", 15 | name="phone_prefix", 16 | field=models.CharField(blank=True, max_length=20, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /organisation/migrations/0007_alter_organisationaddress_street.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2022-01-08 17:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("organisation", "0006_alter_organisationaddress_phone_prefix"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="organisationaddress", 15 | name="street", 16 | field=models.CharField(blank=True, max_length=255), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /organisation/migrations/0003_organisationentity_short_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-04 04:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("organisation", "0002_alter_organisationentity_parent"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="organisationentity", 15 | name="short_name", 16 | field=models.CharField(blank=True, max_length=255, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /organisation/migrations/0008_alter_organisationaddress_postal_code.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2022-01-08 17:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("organisation", "0007_alter_organisationaddress_street"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="organisationaddress", 15 | name="postal_code", 16 | field=models.CharField(blank=True, max_length=5), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /organisation/migrations/0009_alter_organisationaddress_postal_code.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2022-01-08 17:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("organisation", "0008_alter_organisationaddress_postal_code"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="organisationaddress", 15 | name="postal_code", 16 | field=models.CharField(blank=True, max_length=20), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /organisation/migrations/0006_alter_organisationaddress_phone_prefix.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2022-01-08 17:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("organisation", "0005_organisationaddress_phone_prefix"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="organisationaddress", 15 | name="phone_prefix", 16 | field=models.CharField(blank=True, max_length=40, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /.ebextensions/03-container-commands.config: -------------------------------------------------------------------------------- 1 | container_commands: 2 | 01_collectstatic: 3 | command: | 4 | export $(cat /opt/elasticbeanstalk/deployment/env | xargs) 5 | source /var/app/venv/staging-LQM1lest/bin/activate 6 | cd /var/app/staging/ 7 | python manage.py collectstatic --noinput --settings settings.configs.production 8 | 02_migrate: 9 | command: | 10 | export $(cat /opt/elasticbeanstalk/deployment/env | xargs) 11 | source /var/app/venv/staging-LQM1lest/bin/activate 12 | cd /var/app/staging/ 13 | python manage.py migrate --settings settings.configs.production -------------------------------------------------------------------------------- /claims/migrations/0007_auto_20211204_1230.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-04 12:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("claims", "0006_alter_claimtype_value_schema"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="valueclaim", 15 | name="value", 16 | ), 17 | migrations.AddField( 18 | model_name="claim", 19 | name="value", 20 | field=models.JSONField(blank=True, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /oauth/migrations/0002_auto_20210206_1222.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.5 on 2021-02-06 12:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("oauth", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="userprofile", 15 | name="language", 16 | field=models.CharField( 17 | choices=[("en-US", "English"), ("de-DE", "German")], 18 | default="en-us", 19 | max_length=20, 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # strukturen 2 | This is the backend of "Strukturen". A service that will eventually offer you a machine readable representation of the federal public service of Germany. 3 | 4 | Including things like: 5 | - Organisations 6 | - Organisation Charts 7 | 8 | This is the services that provides the main graphql backend, authentication and access management. 9 | 10 | ## Microservices 11 | - [strukturen-ml](https://github.com/bundesAPI/strukturen-ml/) - Allows you to parse orgcharts with computer vision and nlp 12 | - [strukturen-import-ui](https://github.com/bundesAPI/strukturen-import-ui/) User interface to manually review orgcharts before import 13 | -------------------------------------------------------------------------------- /orgcharts/migrations/0007_orgcharterror_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2022-01-06 16:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("orgcharts", "0006_orgcharterror"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="orgcharterror", 15 | name="status", 16 | field=models.CharField( 17 | choices=[("NEW", "new"), ("RESOLVED", "resolved")], 18 | default="NEW", 19 | max_length=20, 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /claims/migrations/0002_auto_20211203_1338.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-03 13:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("contenttypes", "0002_remove_content_type_name"), 10 | ("claims", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name="claimtype", 16 | name="content_type", 17 | ), 18 | migrations.AddField( 19 | model_name="claimtype", 20 | name="content_type", 21 | field=models.ManyToManyField(to="contenttypes.ContentType"), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /person/migrations/0004_alter_person_position.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-05 21:06 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("person", "0003_personposition_positionabbreviation"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="person", 16 | name="position", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.SET_NULL, 21 | to="person.positionabbreviation", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /organisation/migrations/0002_alter_organisationentity_parent.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-03 20:07 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("organisation", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="organisationentity", 16 | name="parent", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.SET_NULL, 21 | to="organisation.organisationentity", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /person/migrations/0006_auto_20220327_0940.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-27 09:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("person", "0005_alter_person_position"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="personposition", 15 | name="female_name", 16 | field=models.CharField(blank=True, max_length=255, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name="personposition", 20 | name="gender_neutral_name", 21 | field=models.CharField(blank=True, max_length=255, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.configs.dev") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /orgcharts/migrations/0005_alter_orgchart_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-18 01:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("orgcharts", "0004_auto_20211212_1532"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="orgchart", 15 | name="status", 16 | field=models.CharField( 17 | choices=[ 18 | ("NEW", "new"), 19 | ("PARSED", "parsed"), 20 | ("IMPORTED", "imported"), 21 | ], 22 | default="NEW", 23 | max_length=20, 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /person/migrations/0005_alter_person_position.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-28 13:18 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("person", "0004_alter_person_position"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="person", 16 | name="position", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.SET_NULL, 21 | related_name="persons", 22 | to="person.positionabbreviation", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /person/migrations/0002_auto_20211212_1532.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-12 15:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("person", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name="person", 15 | old_name="last_name", 16 | new_name="name", 17 | ), 18 | migrations.RemoveField( 19 | model_name="person", 20 | name="first_name", 21 | ), 22 | migrations.AddField( 23 | model_name="person", 24 | name="position", 25 | field=models.CharField(blank=True, max_length=255, null=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /person/permissions.py: -------------------------------------------------------------------------------- 1 | from serious_django_permissions.permissions import Permission 2 | 3 | from person.models import Person 4 | 5 | 6 | class CanCreatePersonPermission(Permission): 7 | model = Person 8 | description = "can create person" 9 | 10 | @staticmethod 11 | def has_permission(context): 12 | return context.user.has_perm(CanCreatePersonPermission) 13 | 14 | @staticmethod 15 | def has_object_permission(context, obj): 16 | return True 17 | 18 | 19 | class CanUpdatePersonPermission(Permission): 20 | model = Person 21 | description = "can update person" 22 | 23 | @staticmethod 24 | def has_permission(context): 25 | return context.user.has_perm(CanUpdatePersonPermission) 26 | 27 | @staticmethod 28 | def has_object_permission(context, obj): 29 | return True 30 | -------------------------------------------------------------------------------- /settings/search.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from django.conf import settings 3 | from opensearchpy import AWSV4SignerAuth, OpenSearch, RequestsHttpConnection 4 | 5 | 6 | def get_search_client() -> OpenSearch: 7 | # TODO: remove me and move to the opensearch-django-dsl 8 | credentials = boto3.session.Session( 9 | region_name=settings.AWS_EB_DEFAULT_REGION, 10 | aws_access_key_id=settings.AWS_ACCESS_KEY_ID, 11 | aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, 12 | ).get_credentials() 13 | auth = AWSV4SignerAuth(credentials, settings.AWS_EB_DEFAULT_REGION) 14 | client = OpenSearch( 15 | hosts=[{"host": settings.OPEN_SEARCH_CLUSTER_ENDPOINT, "port": 443}], 16 | http_auth=auth, 17 | use_ssl=True, 18 | verify_certs=True, 19 | connection_class=RequestsHttpConnection, 20 | ) 21 | return client 22 | -------------------------------------------------------------------------------- /claims/permissions.py: -------------------------------------------------------------------------------- 1 | from serious_django_permissions.permissions import Permission 2 | 3 | from claims.models import ValueClaim, RelationshipClaim 4 | 5 | 6 | class CanCreateValueClaimPermission(Permission): 7 | model = ValueClaim 8 | description = "can create ValueClaim" 9 | 10 | @staticmethod 11 | def has_permission(context): 12 | return context.user.has_perm(CanCreateValueClaimPermission) 13 | 14 | @staticmethod 15 | def has_object_permission(context, obj): 16 | return True 17 | 18 | 19 | class CanUpdateValueClaimPermission(Permission): 20 | model = ValueClaim 21 | description = "can update ValueClaim" 22 | 23 | @staticmethod 24 | def has_permission(context): 25 | return context.user.has_perm(CanUpdateValueClaimPermission) 26 | 27 | @staticmethod 28 | def has_object_permission(context, obj): 29 | return True 30 | -------------------------------------------------------------------------------- /oauth/migrations/0003_auto_20211208_1559.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-08 15:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("oauth", "0002_auto_20210206_1222"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="userprofile", 15 | name="id", 16 | field=models.BigAutoField( 17 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 18 | ), 19 | ), 20 | migrations.AlterField( 21 | model_name="userprofile", 22 | name="language", 23 | field=models.CharField( 24 | choices=[("en-US", "English"), ("de-DE", "German")], 25 | default="de-DE", 26 | max_length=20, 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /organisation/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from organisation.models import OrganisationEntity, OrganisationAddress 4 | 5 | 6 | class UpdateOrganisationEntityForm(forms.ModelForm): 7 | class Meta: 8 | model = OrganisationEntity 9 | fields = ["name", "short_name", "parent", "locations"] 10 | 11 | 12 | class CreateOrganisationEntityForm(forms.ModelForm): 13 | class Meta: 14 | model = OrganisationEntity 15 | fields = ["name", "short_name", "parent", "locations"] 16 | 17 | 18 | class UpdateOrganisationAddressForm(forms.ModelForm): 19 | class Meta: 20 | model = OrganisationAddress 21 | fields = ["name", "street", "city", "postal_code", "country", "phone_prefix"] 22 | 23 | 24 | class CreateOrganisationAddressForm(forms.ModelForm): 25 | class Meta: 26 | model = OrganisationAddress 27 | fields = ["name", "street", "city", "postal_code", "country", "phone_prefix"] 28 | -------------------------------------------------------------------------------- /claims/migrations/0003_auto_20211203_2007.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-03 20:07 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("contenttypes", "0002_remove_content_type_name"), 11 | ("claims", "0002_auto_20211203_1338"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="claim", 17 | name="content_type", 18 | field=models.ForeignKey( 19 | default=None, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | to="contenttypes.contenttype", 22 | ), 23 | preserve_default=False, 24 | ), 25 | migrations.AddField( 26 | model_name="claim", 27 | name="object_id", 28 | field=models.PositiveIntegerField(default=0), 29 | preserve_default=False, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /settings/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from claims import schema as claims_schema 3 | from person import schema as person_schema 4 | from organisation import schema as organisation_schema 5 | from orgcharts import schema as orgcharts_schema 6 | from oauth import schema as oauth_schema 7 | 8 | 9 | class Query( 10 | person_schema.Query, 11 | claims_schema.schema.Query, 12 | orgcharts_schema.Query, 13 | oauth_schema.Query, 14 | organisation_schema.Query, 15 | graphene.ObjectType, 16 | ): 17 | # This class will inherit from multiple Queries 18 | # as we begin to add more apps to our project 19 | pass 20 | 21 | 22 | class Mutation( 23 | person_schema.Mutation, 24 | organisation_schema.Mutation, 25 | orgcharts_schema.Mutation, 26 | claims_schema.Mutation, 27 | oauth_schema.Mutation, 28 | ): 29 | # This class will inherit from multiple Queries 30 | # as we begin to add more apps to our project 31 | pass 32 | 33 | 34 | schema = graphene.Schema(query=Query, mutation=Mutation) 35 | -------------------------------------------------------------------------------- /oauth/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import Group, Permission 3 | from django.db.models.signals import post_save 4 | from django.dispatch import receiver 5 | 6 | from django.utils.translation import gettext as _ 7 | from django.conf import settings 8 | 9 | 10 | class UserProfile(models.Model): 11 | """ 12 | represents user profile settings 13 | """ 14 | 15 | user = models.OneToOneField( 16 | settings.AUTH_USER_MODEL, related_name="profile", on_delete=models.CASCADE 17 | ) 18 | profile_picture = models.ImageField(null=True, blank=True) 19 | profile_setup_done = models.BooleanField(default=False) 20 | language = models.CharField( 21 | default=settings.LANGUAGE_CODE, choices=settings.LANGUAGES, max_length=20 22 | ) 23 | 24 | def __str__(self): 25 | return f"{self.user}: Profile" 26 | 27 | 28 | @receiver(post_save, sender=settings.AUTH_USER_MODEL) 29 | def create_user_profile(sender, instance, **kwargs): 30 | UserProfile.objects.get_or_create(user=instance) 31 | -------------------------------------------------------------------------------- /settings/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.conf import settings 4 | from django.http import HttpResponse 5 | from django.shortcuts import render 6 | from django.template.response import TemplateResponse 7 | from django.contrib.auth.decorators import login_required 8 | 9 | # Create your views here. 10 | from oauth.services import UserProfileService 11 | from settings.search import get_search_client 12 | 13 | 14 | def home(request): 15 | """serve 200 at /""" 16 | return HttpResponse( 17 | "Hey there! Just another strukturen backend.", content_type="text/plain" 18 | ) 19 | 20 | 21 | def status(request): 22 | """serve 200 at /""" 23 | client = get_search_client() 24 | return HttpResponse( 25 | json.dumps( 26 | {"search": client.info(), "aws": {"region": settings.AWS_EB_DEFAULT_REGION}} 27 | ), 28 | content_type="application/json", 29 | ) 30 | 31 | 32 | @login_required 33 | def account(request): 34 | """renders a basic user account view""" 35 | return TemplateResponse(request, "account.html", {"user": request.user}) 36 | -------------------------------------------------------------------------------- /orgcharts/management/commands/update_orgcharts.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from io import BytesIO 3 | from tempfile import NamedTemporaryFile 4 | 5 | import requests 6 | from django.core.files.base import ContentFile 7 | from django.core.management.base import BaseCommand, CommandError 8 | from django.db import IntegrityError 9 | from django.db.models.fields import files 10 | 11 | from orgcharts.models import OrgChartURL, OrgChart 12 | 13 | 14 | class Command(BaseCommand): 15 | help = "Update the orgcharts" 16 | 17 | def handle(self, *args, **options): 18 | for url in OrgChartURL.objects.order_by("-created_at").all(): 19 | m = hashlib.md5() 20 | blob = requests.get(url.url, stream=True) 21 | m.update(blob.content) 22 | try: 23 | OrgChart.objects.create( 24 | org_chart_url=url, 25 | document=files.File(ContentFile(blob.content), "orgchart.pdf"), 26 | document_hash=m.hexdigest(), 27 | ) 28 | except IntegrityError: 29 | print("already stored") 30 | -------------------------------------------------------------------------------- /settings/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin 3 | from django.template.context_processors import static 4 | from django.urls import path, include 5 | from django.views.decorators.csrf import csrf_exempt 6 | from django.views.static import serve 7 | from graphene_file_upload.django import FileUploadGraphQLView 8 | from settings.views import home, account, status 9 | 10 | from django.conf import settings 11 | 12 | urlpatterns = [ 13 | path("admin/", admin.site.urls), 14 | path("graphql", csrf_exempt(FileUploadGraphQLView.as_view(graphiql=True))), 15 | path(r"oauth/", include(("oauth.urls", "oauth"), namespace="oauth2_provider")), 16 | path("auth/", include("django.contrib.auth.urls")), 17 | path("social/", include("social_django.urls", namespace="social")), 18 | path("account/", account, name="account"), 19 | path("status/", status), 20 | path("", home), 21 | ] 22 | if settings.DEBUG: 23 | urlpatterns += [ 24 | url( 25 | r"^media/(?P.*)$", 26 | serve, 27 | { 28 | "document_root": settings.MEDIA_ROOT, 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /oauth/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path, include 2 | 3 | 4 | from oauth2_provider import urls as oauth2_provider_urls 5 | import oauth2_provider.views as oauth2_views 6 | from oauth2_provider.views import IntrospectTokenView, JwksInfoView, UserInfoView 7 | from rest_framework_social_oauth2.views import invalidate_sessions 8 | 9 | from oauth.views import TokenView 10 | 11 | urlpatterns = [ 12 | path( 13 | "authorize/", 14 | oauth2_views.AuthorizationView.as_view( 15 | template_name="oauth/authorization.html" 16 | ), 17 | name="authorize", 18 | ), 19 | path("token/", TokenView.as_view(), name="token"), 20 | path("revoke-token/", oauth2_views.RevokeTokenView.as_view(), name="revoke_token"), 21 | path("invalidate-sessions/", invalidate_sessions, name="invalidate_sessions"), 22 | path(r"introspect/", IntrospectTokenView.as_view(), name="introspect"), 23 | re_path(r"^jwks/$", JwksInfoView.as_view(), name="jwks-info"), 24 | re_path(r"^userinfo/$", UserInfoView.as_view(), name="user-info"), 25 | ] 26 | 27 | urlpatterns += oauth2_provider_urls.management_urlpatterns 28 | urlpatterns += oauth2_provider_urls.oidc_urlpatterns 29 | -------------------------------------------------------------------------------- /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | max-parallel: 4 15 | matrix: 16 | python-version: [3.9] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install Dependencies 25 | run: | 26 | python -m pip install --upgrade poetry 27 | poetry install 28 | - name: Run Tests 29 | run: | 30 | poetry run python manage.py test --settings=settings.configs.test 31 | - name: Run Test Coverage 32 | run: | 33 | poetry run coverage run manage.py test --settings=settings.configs.test 34 | poetry run coverage report 35 | poetry run coverage xml 36 | 37 | - name: "Upload coverage to Codecov" 38 | uses: codecov/codecov-action@v2 39 | with: 40 | fail_ci_if_error: true 41 | token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos 42 | 43 | 44 | -------------------------------------------------------------------------------- /orgcharts/migrations/0004_auto_20211212_1532.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-12 15:32 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("orgcharts", "0003_alter_orgchart_unique_together"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="orgchart", 16 | name="raw_source", 17 | field=models.JSONField(blank=True, null=True), 18 | ), 19 | migrations.AddField( 20 | model_name="orgchart", 21 | name="status", 22 | field=models.CharField( 23 | choices=[("NEW", "new"), ("IMPORTED", "imported")], 24 | default="NEW", 25 | max_length=20, 26 | ), 27 | ), 28 | migrations.AlterField( 29 | model_name="orgchart", 30 | name="org_chart_url", 31 | field=models.ForeignKey( 32 | on_delete=django.db.models.deletion.CASCADE, 33 | related_name="orgchart_documents", 34 | to="orgcharts.orgcharturl", 35 | ), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /oauth/tests/services/test_user_profile_service.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase, RequestFactory 3 | from django.conf import settings 4 | 5 | from serious_django_permissions.management.commands import ( 6 | create_groups, 7 | create_permissions, 8 | ) 9 | 10 | from oauth.services import UserProfileService 11 | 12 | 13 | class UserProfileServiceTest(TestCase): 14 | def setUp(self): 15 | create_permissions.Command().handle() 16 | create_groups.Command().handle() 17 | self.test_user = get_user_model().objects.create_user( 18 | username="tester", password="refefe" 19 | ) 20 | 21 | def test_update_profile_basic_information(self): 22 | user = UserProfileService.update_user_basic_information( 23 | self.test_user, first_name="Bernd", last_name="Lauert", language="de-DE" 24 | ) 25 | self.assertEqual(user.first_name, "Bernd") 26 | self.assertEqual(user.last_name, "Lauert") 27 | self.assertEqual(user.profile.language, "de-DE") 28 | 29 | def test_get_available_languages(self): 30 | languages = UserProfileService.get_available_language(self.test_user) 31 | self.assertEqual(len(languages), len(settings.LANGUAGES)) 32 | -------------------------------------------------------------------------------- /person/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-03 13:24 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ("claims", "0001_initial"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Person", 18 | fields=[ 19 | ( 20 | "entity_ptr", 21 | models.OneToOneField( 22 | auto_created=True, 23 | on_delete=django.db.models.deletion.CASCADE, 24 | parent_link=True, 25 | primary_key=True, 26 | serialize=False, 27 | to="claims.entity", 28 | ), 29 | ), 30 | ("last_name", models.CharField(max_length=255)), 31 | ("first_name", models.CharField(blank=True, max_length=255, null=True)), 32 | ], 33 | options={ 34 | "abstract": False, 35 | "base_manager_name": "objects", 36 | }, 37 | bases=("claims.entity",), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /organisation/models.py: -------------------------------------------------------------------------------- 1 | import reversion 2 | from django.db import models 3 | from claims.models import Entity 4 | 5 | 6 | @reversion.register() 7 | class OrganisationAddress(models.Model): 8 | name = models.CharField(max_length=255) 9 | street = models.CharField(max_length=255, blank=True) 10 | city = models.CharField(max_length=255) 11 | postal_code = models.CharField(max_length=20, blank=True) 12 | country = models.CharField(max_length=2) 13 | phone_prefix = models.CharField(max_length=40, blank=True, null=True) 14 | 15 | def __str__(self): 16 | return self.name 17 | 18 | 19 | @reversion.register() 20 | class OrganisationEntity(Entity): 21 | name = models.CharField(max_length=255) 22 | short_name = models.CharField(max_length=255, null=True, blank=True) 23 | parent = models.ForeignKey( 24 | "OrganisationEntity", 25 | null=True, 26 | blank=True, 27 | on_delete=models.SET_NULL, 28 | related_name="children", 29 | ) 30 | locations = models.ManyToManyField( 31 | OrganisationAddress, related_name="organisations", blank=True 32 | ) 33 | 34 | def __str__(self): 35 | if not self.parent: 36 | return self.name 37 | else: 38 | return f"{self.name} - {self.parent}" 39 | -------------------------------------------------------------------------------- /claims/migrations/0004_auto_20211203_2032.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-03 20:32 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("contenttypes", "0002_remove_content_type_name"), 11 | ("claims", "0003_auto_20211203_2007"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name="claim", 17 | options={"base_manager_name": "objects"}, 18 | ), 19 | migrations.AlterModelOptions( 20 | name="relationshipclaim", 21 | options={"base_manager_name": "objects"}, 22 | ), 23 | migrations.AlterModelOptions( 24 | name="valueclaim", 25 | options={"base_manager_name": "objects"}, 26 | ), 27 | migrations.AddField( 28 | model_name="claim", 29 | name="polymorphic_ctype", 30 | field=models.ForeignKey( 31 | editable=False, 32 | null=True, 33 | on_delete=django.db.models.deletion.CASCADE, 34 | related_name="polymorphic_claims.claim_set+", 35 | to="contenttypes.contenttype", 36 | ), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /orgcharts/migrations/0006_orgcharterror.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2022-01-06 16:10 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("orgcharts", "0005_alter_orgchart_status"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="OrgChartError", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("message", models.CharField(max_length=1000)), 27 | ("created_at", models.DateTimeField(auto_now_add=True)), 28 | ( 29 | "org_chart_url", 30 | models.ForeignKey( 31 | on_delete=django.db.models.deletion.CASCADE, 32 | related_name="errors", 33 | to="orgcharts.orgcharturl", 34 | ), 35 | ), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "strukturen" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Lilith Wittmann"] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.9" 9 | Django = "^3.2.12" 10 | django-reversion = "^4.0.1" 11 | django-polymorphic = "^3.1.0" 12 | django-admin-json-editor = "^0.2.3" 13 | graphene-django = "^2.15.0" 14 | django-filter = "^21.1" 15 | serious-django-services = "^0.5" 16 | serious-django-permissions = "^0.17" 17 | serious-django-graphene = "^0.5.1" 18 | django-graphene-permissions = "^0.0.4" 19 | jsonschema = "^4.2.1" 20 | django-oauth-toolkit = "^1.5.0" 21 | django-rest-framework-social-oauth2 = "^1.1.0" 22 | django-oauth-toolkit-jwt = {git = "https://github.com/LilithWittmann/django-oauth-toolkit-jwt/"} 23 | Pillow = "^9.0.0" 24 | django-cors-headers = "^3.10.0" 25 | django-crispy-forms = "^1.13.0" 26 | graphene-file-upload = "^1.3.0" 27 | boto3 = "^1.20.24" 28 | psycopg2 = "^2.9.2" 29 | django-storages = "^1.12.3" 30 | sentry-sdk = "^1.5.1" 31 | django-secrets-manager = "^0.1.13" 32 | django-allow-cidr = "^0.3.1" 33 | social-auth-app-django = "^5.0.0" 34 | django-opensearch-dsl = "^0.2.0" 35 | 36 | 37 | [tool.poetry.dev-dependencies] 38 | black = "^21.12b0" 39 | coverage = "^6.3.1" 40 | 41 | [build-system] 42 | requires = ["poetry-core>=1.0.0"] 43 | build-backend = "poetry.core.masonry.api" 44 | -------------------------------------------------------------------------------- /orgcharts/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from orgcharts.models import OrgChartURL, OrgChartError, OrgChart 4 | 5 | 6 | class UpdateOrgChartURLForm(forms.ModelForm): 7 | class Meta: 8 | model = OrgChartURL 9 | fields = [ 10 | "organisation_entity", 11 | "url", 12 | ] 13 | 14 | 15 | class CreateOrgChartURLForm(forms.ModelForm): 16 | class Meta: 17 | model = OrgChartURL 18 | fields = [ 19 | "organisation_entity", 20 | "url", 21 | ] 22 | 23 | 24 | class CreateOrgChartErrorForm(forms.ModelForm): 25 | class Meta: 26 | model = OrgChartError 27 | fields = [ 28 | "org_chart_url", 29 | "message", 30 | ] 31 | 32 | 33 | class UpdateOrgChartErrorForm(forms.ModelForm): 34 | class Meta: 35 | model = OrgChartError 36 | fields = [ 37 | "status", 38 | ] 39 | 40 | 41 | class CreateOrgChartForm(forms.ModelForm): 42 | class Meta: 43 | model = OrgChart 44 | fields = [ 45 | "org_chart_url", 46 | "document", 47 | "document_hash", 48 | ] 49 | 50 | 51 | class UpdateOrgChartForm(forms.ModelForm): 52 | class Meta: 53 | model = OrgChart 54 | fields = [ 55 | "raw_source", 56 | "status", 57 | ] 58 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy master 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | 12 | - name: Checkout source code 13 | uses: actions/checkout@v2 14 | 15 | - name: install poetry 16 | run: pip install poetry 17 | # TODO: find a way todo this with hashes 18 | - name: generate requirements.txt 19 | run: poetry export --without-hashes -f requirements.txt --output requirements.txt 20 | 21 | - name: Generate deployment package 22 | run: zip -r deploy.zip . -x '*.git*' 23 | 24 | - name: Deploy to EB 25 | uses: einaregilsson/beanstalk-deploy@v20 26 | with: 27 | aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }} 28 | aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 29 | application_name: strukturen-dev 30 | environment_name: strukturen 31 | version_description: ${{github.SHA}} 32 | version_label: ${{github.SHA}} 33 | region: eu-central-1 34 | deployment_package: deploy.zip 35 | - name: Create Sentry release 36 | uses: getsentry/action-release@v1 37 | env: 38 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 39 | SENTRY_ORG: ${{ secrets.SENTRY_ORG }} 40 | SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} 41 | with: 42 | environment: production 43 | -------------------------------------------------------------------------------- /settings/templates/auth_base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load crispy_forms_tags %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | {{ application.application.name }} 13 | 14 | 15 | 16 |
17 | 28 | 29 | {% load i18n %} 30 | {% block content %} 31 |
32 |
33 |
34 | {% block form_content %} {% endblock %} 35 |
36 |
37 |
38 | {% endblock %} 39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /organisation/documents.py: -------------------------------------------------------------------------------- 1 | from django_opensearch_dsl import Document, fields 2 | from django_opensearch_dsl.registries import registry 3 | 4 | from organisation.models import OrganisationEntity 5 | 6 | 7 | @registry.register_document 8 | class OrganisationEntityDocument(Document): 9 | class Index: 10 | name = "organisations" # Name of the Opensearch index 11 | settings = { # See Opensearch Indices API reference for available settings 12 | "number_of_shards": 1, 13 | "number_of_replicas": 0, 14 | } 15 | # Configure how the index should be refreshed after an update. 16 | # See Opensearch documentation for supported options. 17 | # This per-Document setting overrides settings.OPENSEARCH_DSL_AUTO_REFRESH. 18 | auto_refresh = False 19 | 20 | class Django: 21 | model = OrganisationEntity 22 | queryset_pagination = 128 23 | fields = [ 24 | "name", 25 | "short_name", 26 | ] 27 | 28 | id = fields.LongField() 29 | locations = fields.NestedField( 30 | properties={ 31 | "id": fields.LongField(), 32 | "name": fields.KeywordField(), 33 | "street": fields.KeywordField(), 34 | "city": fields.KeywordField(), 35 | "postal_code": fields.KeywordField(), 36 | "country": fields.KeywordField(), 37 | "phone_prefix": fields.KeywordField(), 38 | } 39 | ) 40 | -------------------------------------------------------------------------------- /orgcharts/management/commands/preprocess_orgcharts.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from io import BytesIO 3 | from tempfile import NamedTemporaryFile 4 | 5 | import requests 6 | from django.conf import settings 7 | from django.core.files.base import ContentFile 8 | from django.core.management.base import BaseCommand, CommandError 9 | from django.db import IntegrityError 10 | from django.db.models.fields import files 11 | from graphql_relay import to_global_id 12 | 13 | from orgcharts.models import OrgChartURL, OrgChart, OrgChartStatusChoices 14 | from orgcharts.schema import OrgChartNode 15 | 16 | 17 | class Command(BaseCommand): 18 | help = "Parse new orgcharts" 19 | 20 | def handle(self, *args, **options): 21 | for org_chart in OrgChart.objects.filter( 22 | status__in=[OrgChartStatusChoices.NEW] 23 | ): 24 | try: 25 | # yeah its not nice but it does the job 26 | orgchart_global_id = to_global_id(OrgChartNode.__name__, org_chart.id) 27 | orgchart_data = requests.get( 28 | f"{settings.ML_BACKEND_BASE_URL}/analyze-orgchart/", 29 | params={"orgchart_id": orgchart_global_id, "page": 0}, 30 | ) 31 | print(orgchart_data) 32 | org_chart.raw_source = orgchart_data.json() 33 | org_chart.status = OrgChartStatusChoices.PARSED 34 | org_chart.save() 35 | except Exception as e: 36 | print(e) 37 | -------------------------------------------------------------------------------- /person/models.py: -------------------------------------------------------------------------------- 1 | import reversion 2 | from django.db import models 3 | from claims.models import Entity 4 | from django.utils.translation import ugettext as _ 5 | 6 | 7 | @reversion.register() 8 | class PersonPosition(models.Model): 9 | name = models.CharField(max_length=255) 10 | female_name = models.CharField(max_length=255, blank=True, null=True) 11 | gender_neutral_name = models.CharField(max_length=255, blank=True, null=True) 12 | plural_name = models.CharField(max_length=255, blank=True, null=True) 13 | 14 | def __str__(self): 15 | return self.name 16 | 17 | 18 | class Gender(models.TextChoices): 19 | MALE = "MALE", _("Male") 20 | FEMALE = "FEMALE", _("Female") 21 | UNKOWN = "UNKOWN", _("Unknown") 22 | 23 | 24 | @reversion.register() 25 | class PositionAbbreviation(models.Model): 26 | name = models.CharField(max_length=10) 27 | gender = models.CharField(max_length=10, choices=Gender.choices) 28 | position = models.ForeignKey( 29 | PersonPosition, related_name="abbreviations", on_delete=models.CASCADE 30 | ) 31 | 32 | def __str__(self): 33 | return self.name 34 | 35 | 36 | @reversion.register() 37 | class Person(Entity): 38 | name = models.CharField(max_length=255) 39 | position = models.ForeignKey( 40 | PositionAbbreviation, 41 | null=True, 42 | blank=True, 43 | on_delete=models.SET_NULL, 44 | related_name="persons", 45 | ) 46 | 47 | def __str__(self): 48 | return self.name 49 | -------------------------------------------------------------------------------- /organisation/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-03 13:24 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ("claims", "0001_initial"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="OrganisationEntity", 18 | fields=[ 19 | ( 20 | "entity_ptr", 21 | models.OneToOneField( 22 | auto_created=True, 23 | on_delete=django.db.models.deletion.CASCADE, 24 | parent_link=True, 25 | primary_key=True, 26 | serialize=False, 27 | to="claims.entity", 28 | ), 29 | ), 30 | ("name", models.CharField(max_length=255)), 31 | ( 32 | "parent", 33 | models.ForeignKey( 34 | null=True, 35 | on_delete=django.db.models.deletion.SET_NULL, 36 | to="organisation.organisationentity", 37 | ), 38 | ), 39 | ], 40 | options={ 41 | "abstract": False, 42 | "base_manager_name": "objects", 43 | }, 44 | bases=("claims.entity",), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /organisation/permissions.py: -------------------------------------------------------------------------------- 1 | from serious_django_permissions.permissions import Permission 2 | 3 | from person.models import Person 4 | 5 | 6 | class CanCreateOrganisationEntityPermission(Permission): 7 | model = Person 8 | description = "can create organisation entity" 9 | 10 | @staticmethod 11 | def has_permission(context): 12 | return context.user.has_perm(CanCreateOrganisationEntityPermission) 13 | 14 | @staticmethod 15 | def has_object_permission(context, obj): 16 | return True 17 | 18 | 19 | class CanUpdateOrganisationEntityPermission(Permission): 20 | model = Person 21 | description = "can update organisation entity" 22 | 23 | @staticmethod 24 | def has_permission(context): 25 | return context.user.has_perm(CanUpdateOrganisationEntityPermission) 26 | 27 | @staticmethod 28 | def has_object_permission(context, obj): 29 | return True 30 | 31 | 32 | class CanCreateOrganisationAddressPermission(Permission): 33 | model = Person 34 | description = "can create organisation address" 35 | 36 | @staticmethod 37 | def has_permission(context): 38 | return context.user.has_perm(CanCreateOrganisationAddressPermission) 39 | 40 | @staticmethod 41 | def has_object_permission(context, obj): 42 | return True 43 | 44 | 45 | class CanUpdateOrganisationAddressPermission(Permission): 46 | model = Person 47 | description = "can create organisation address" 48 | 49 | @staticmethod 50 | def has_permission(context): 51 | return context.user.has_perm(CanUpdateOrganisationAddressPermission) 52 | 53 | @staticmethod 54 | def has_object_permission(context, obj): 55 | return True 56 | -------------------------------------------------------------------------------- /settings/templates/oauth/authorization.html: -------------------------------------------------------------------------------- 1 | {% extends "oauth/auth_base.html" %} 2 | {% load static %} 3 | 4 | 5 | {% load i18n %} 6 | {% block content %} 7 |
8 |
9 |
10 | {% if not error %} 11 |
12 |

{{ application.application.name }} Login

13 |
14 | {% csrf_token %} 15 | 16 | {% for field in form %} 17 | {% if field.is_hidden %} 18 | {{ field }} 19 | {% endif %} 20 | {% endfor %} 21 | {{ application.application.name }} wants access to the following data: 22 |
    23 | {% for scope in scopes_descriptions %} 24 |
  • {{ scope }}
  • 25 | {% endfor %} 26 |
27 | 28 | {{ form.errors }} 29 | {{ form.non_field_errors }} 30 | 31 |
32 |
33 | 34 | 35 |
36 |
37 |
38 | 39 | {% else %} 40 |

Error: {{ error.error }}

41 |

There was an issue with your request: {{ error.description }}

42 | 43 | {% endif %} 44 |
45 |
46 |
47 | {% endblock %} -------------------------------------------------------------------------------- /settings/templates/oauth/auth_base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load crispy_forms_tags %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | {{ application.application.name }} 13 | 14 | 15 | 16 |
17 | 28 | 29 | {% load i18n %} 30 | 31 | {% if messages %} 32 |
33 |
34 |
    35 | {% for message in messages %} 36 |
  • {{ message }}
  • 37 | {% endfor %} 38 |
39 |
40 |
41 | {% endif %} 42 | 43 | {% block content %} 44 | {% endblock %} 45 |
46 | 47 | 48 | -------------------------------------------------------------------------------- /organisation/migrations/0004_auto_20211218_0113.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-18 01:13 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("organisation", "0003_organisationentity_short_name"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="OrganisationAddress", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("name", models.CharField(max_length=255)), 27 | ("street", models.CharField(max_length=255)), 28 | ("city", models.CharField(max_length=255)), 29 | ("postal_code", models.CharField(max_length=5)), 30 | ("country", models.CharField(max_length=2)), 31 | ], 32 | ), 33 | migrations.AlterField( 34 | model_name="organisationentity", 35 | name="parent", 36 | field=models.ForeignKey( 37 | blank=True, 38 | null=True, 39 | on_delete=django.db.models.deletion.SET_NULL, 40 | related_name="children", 41 | to="organisation.organisationentity", 42 | ), 43 | ), 44 | migrations.AddField( 45 | model_name="organisationentity", 46 | name="locations", 47 | field=models.ManyToManyField( 48 | blank=True, 49 | related_name="organisations", 50 | to="organisation.OrganisationAddress", 51 | ), 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /oauth/oauth_backend.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | import jwt 3 | from oauth2_provider_jwt.utils import decode_jwt 4 | from oauthlib.common import Request 5 | from rest_framework import exceptions 6 | from oauth2_provider.oauth2_backends import get_oauthlib_core 7 | 8 | 9 | UserModel = get_user_model() 10 | OAuthLibCore = get_oauthlib_core() 11 | 12 | 13 | class OAuth2Backend: 14 | """ 15 | Authenticate against an OAuth2 access token 16 | """ 17 | 18 | def authenticate(self, request=None, **credentials): 19 | if request is not None: 20 | if "Authorization" in request.headers: 21 | hdr = request.headers["Authorization"].split() 22 | 23 | try: 24 | payload = decode_jwt(hdr[1]) 25 | except jwt.ExpiredSignatureError: 26 | msg = "Signature has expired." 27 | raise exceptions.NotAuthenticated(msg) 28 | except jwt.DecodeError: 29 | msg = "Error decoding signature." 30 | raise exceptions.NotAuthenticated(msg) 31 | except (jwt.InvalidTokenError, jwt.InvalidSignatureError): 32 | raise exceptions.NotAuthenticated() 33 | 34 | uri, http_method, body, headers = OAuthLibCore._extract_params(request) 35 | headers["HTTP_AUTHORIZATION"] = " ".join( 36 | [hdr[0], payload["access_token"]] 37 | ) 38 | headers["Authorization"] = " ".join([hdr[0], payload["access_token"]]) 39 | 40 | valid, r = OAuthLibCore.server.verify_request( 41 | uri, http_method, body, headers, scopes=[] 42 | ) 43 | if valid: 44 | return r.user 45 | return None 46 | 47 | def get_user(self, user_id): 48 | try: 49 | return UserModel.objects.get(pk=user_id) 50 | except UserModel.DoesNotExist: 51 | return None 52 | -------------------------------------------------------------------------------- /orgcharts/permissions.py: -------------------------------------------------------------------------------- 1 | from serious_django_permissions.permissions import Permission 2 | 3 | from orgcharts.models import OrgChartURL, OrgChart, OrgChartError 4 | from person.models import Person 5 | 6 | 7 | class CanCreateOrgChartURLPermission(Permission): 8 | model = OrgChartURL 9 | description = "can create orgchart url entity" 10 | 11 | @staticmethod 12 | def has_permission(context): 13 | return context.user.has_perm(CanCreateOrgChartURLPermission) 14 | 15 | @staticmethod 16 | def has_object_permission(context, obj): 17 | return True 18 | 19 | 20 | class CanImportOrgChartPermission(Permission): 21 | model = OrgChart 22 | description = "can import the data of an orgchart initially" 23 | 24 | @staticmethod 25 | def has_permission(context): 26 | return context.user.has_perm(CanImportOrgChartPermission) 27 | 28 | @staticmethod 29 | def has_object_permission(context, obj): 30 | return True 31 | 32 | 33 | class CanCreateOrgChartPermission(Permission): 34 | model = OrgChart 35 | description = "can create an orgchart" 36 | 37 | @staticmethod 38 | def has_permission(context): 39 | return context.user.has_perm(CanCreateOrgChartPermission) 40 | 41 | @staticmethod 42 | def has_object_permission(context, obj): 43 | return True 44 | 45 | 46 | class CanUpdateOrgChartPermission(Permission): 47 | model = OrgChart 48 | description = "can update an orgchart" 49 | 50 | @staticmethod 51 | def has_permission(context): 52 | return context.user.has_perm(CanUpdateOrgChartPermission) 53 | 54 | @staticmethod 55 | def has_object_permission(context, obj): 56 | return True 57 | 58 | 59 | class CanCreateOrgChartErrorPermission(Permission): 60 | model = OrgChartError 61 | description = "can create orgchart error message" 62 | 63 | @staticmethod 64 | def has_permission(context): 65 | return context.user.has_perm(CanCreateOrgChartErrorPermission) 66 | 67 | @staticmethod 68 | def has_object_permission(context, obj): 69 | return True 70 | -------------------------------------------------------------------------------- /person/migrations/0003_personposition_positionabbreviation.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2022-01-27 17:46 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("person", "0002_auto_20211212_1532"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="PersonPosition", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("name", models.CharField(max_length=255)), 27 | ], 28 | ), 29 | migrations.CreateModel( 30 | name="PositionAbbreviation", 31 | fields=[ 32 | ( 33 | "id", 34 | models.BigAutoField( 35 | auto_created=True, 36 | primary_key=True, 37 | serialize=False, 38 | verbose_name="ID", 39 | ), 40 | ), 41 | ("name", models.CharField(max_length=10)), 42 | ( 43 | "gender", 44 | models.CharField( 45 | choices=[ 46 | ("MALE", "Male"), 47 | ("FEMALE", "Female"), 48 | ("UNKOWN", "Unbekannt"), 49 | ], 50 | max_length=10, 51 | ), 52 | ), 53 | ( 54 | "position", 55 | models.ForeignKey( 56 | on_delete=django.db.models.deletion.CASCADE, 57 | related_name="abbreviations", 58 | to="person.personposition", 59 | ), 60 | ), 61 | ], 62 | ), 63 | ] 64 | -------------------------------------------------------------------------------- /orgcharts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | import reversion 4 | 5 | from organisation.models import OrganisationEntity 6 | 7 | 8 | class OrgChartStatusChoices(models.TextChoices): 9 | NEW = "NEW", _("new") 10 | PARSED = "PARSED", _("parsed") 11 | IMPORTED = "IMPORTED", _("imported") 12 | 13 | 14 | class OrgChartErrorStatusChoices(models.TextChoices): 15 | NEW = "NEW", _("new") 16 | PARSED = "RESOLVED", _("resolved") 17 | 18 | 19 | @reversion.register() 20 | class OrgChartURL(models.Model): 21 | organisation_entity = models.ForeignKey( 22 | OrganisationEntity, on_delete=models.CASCADE, related_name="orgcharts" 23 | ) 24 | created_at = models.DateTimeField(auto_created=True) 25 | url = models.URLField() 26 | 27 | def __str__(self): 28 | return f"{self.organisation_entity} - {self.url}" 29 | 30 | 31 | @reversion.register() 32 | class OrgChart(models.Model): 33 | org_chart_url = models.ForeignKey( 34 | OrgChartURL, on_delete=models.CASCADE, related_name="orgchart_documents" 35 | ) 36 | created_at = models.DateTimeField(auto_now_add=True) 37 | document_hash = models.CharField(max_length=255) 38 | document = models.FileField() 39 | raw_source = models.JSONField(null=True, blank=True) 40 | status = models.CharField( 41 | max_length=20, 42 | choices=OrgChartStatusChoices.choices, 43 | default=OrgChartStatusChoices.NEW, 44 | ) 45 | 46 | def __str__(self): 47 | return f"{self.org_chart_url} - {self.created_at}" 48 | 49 | class Meta: 50 | unique_together = [["org_chart_url", "document_hash"]] 51 | 52 | 53 | @reversion.register() 54 | class OrgChartError(models.Model): 55 | org_chart_url = models.ForeignKey( 56 | OrgChartURL, on_delete=models.CASCADE, related_name="errors" 57 | ) 58 | message = models.CharField(max_length=1000) 59 | status = models.CharField( 60 | max_length=20, 61 | choices=OrgChartErrorStatusChoices.choices, 62 | default=OrgChartErrorStatusChoices.NEW, 63 | ) 64 | created_at = models.DateTimeField(auto_now_add=True) 65 | 66 | def __str__(self): 67 | return self.message 68 | -------------------------------------------------------------------------------- /orgcharts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-08 15:59 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ("organisation", "0003_organisationentity_short_name"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="OrgChartURL", 18 | fields=[ 19 | ( 20 | "id", 21 | models.BigAutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("created_at", models.DateTimeField(auto_created=True)), 29 | ("url", models.URLField()), 30 | ( 31 | "organisation_entity", 32 | models.ForeignKey( 33 | on_delete=django.db.models.deletion.CASCADE, 34 | related_name="orgcharts", 35 | to="organisation.organisationentity", 36 | ), 37 | ), 38 | ], 39 | ), 40 | migrations.CreateModel( 41 | name="OrgChart", 42 | fields=[ 43 | ( 44 | "id", 45 | models.BigAutoField( 46 | auto_created=True, 47 | primary_key=True, 48 | serialize=False, 49 | verbose_name="ID", 50 | ), 51 | ), 52 | ("created_at", models.DateTimeField(auto_created=True)), 53 | ("document_hash", models.CharField(max_length=255)), 54 | ("document", models.FileField(upload_to="")), 55 | ( 56 | "org_chart_url", 57 | models.ForeignKey( 58 | on_delete=django.db.models.deletion.CASCADE, 59 | to="orgcharts.orgcharturl", 60 | ), 61 | ), 62 | ], 63 | ), 64 | ] 65 | -------------------------------------------------------------------------------- /claims/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django_admin_json_editor import JSONEditorWidget 3 | from polymorphic.admin import ( 4 | PolymorphicChildModelAdmin, 5 | PolymorphicParentModelAdmin, 6 | PolymorphicChildModelFilter, 7 | StackedPolymorphicInline, 8 | PolymorphicInlineSupportMixin, 9 | GenericStackedPolymorphicInline, 10 | ) 11 | 12 | from claims.models import ClaimType, ValueClaim, Claim, Entity, RelationshipClaim 13 | from organisation.models import OrganisationEntity 14 | from person.models import Person 15 | 16 | admin.site.register(ClaimType) 17 | 18 | 19 | class ClaimsInline(GenericStackedPolymorphicInline): 20 | class ValueClaimInline(GenericStackedPolymorphicInline.Child): 21 | model = ValueClaim 22 | 23 | def get_form(self, request, obj=None, **kwargs): 24 | if not obj: 25 | schema = { 26 | "type": "array", 27 | "title": "Please create the claim before editing the data", 28 | "items": {}, 29 | } 30 | else: 31 | schema = obj.claim_type.value_schema 32 | widget = JSONEditorWidget(schema, False) 33 | form = super().get_form(request, obj, widgets={"value": widget}, **kwargs) 34 | return form 35 | 36 | class RelationshipClaimInline(GenericStackedPolymorphicInline.Child): 37 | model = RelationshipClaim 38 | 39 | model = Claim 40 | child_inlines = (ValueClaimInline, RelationshipClaimInline) 41 | 42 | 43 | @admin.register(OrganisationEntity) 44 | class OrganisationEntityAdmin( 45 | PolymorphicInlineSupportMixin, PolymorphicChildModelAdmin 46 | ): 47 | show_in_index = True 48 | base_model = Entity 49 | inlines = [ClaimsInline] 50 | search_fields = ["name"] 51 | list_filter = ["parent"] 52 | autocomplete_fields = ["parent"] 53 | 54 | 55 | @admin.register(Person) 56 | class PersonAdmin(PolymorphicInlineSupportMixin, PolymorphicChildModelAdmin): 57 | show_in_index = True 58 | base_model = Entity 59 | inlines = [ClaimsInline] 60 | 61 | 62 | @admin.register(Entity) 63 | class EntityAdmin(PolymorphicInlineSupportMixin, PolymorphicParentModelAdmin): 64 | """The parent model admin""" 65 | 66 | base_model = Entity 67 | child_models = (OrganisationEntity, PersonAdmin) 68 | inlines = [ClaimsInline] 69 | -------------------------------------------------------------------------------- /oauth/services.py: -------------------------------------------------------------------------------- 1 | from django.utils.crypto import get_random_string 2 | 3 | from django.conf import settings 4 | 5 | from serious_django_services import Service, CRUDMixin 6 | 7 | from oauth.forms import UpdateUserProfileForm, CreateUserProfileForm 8 | from oauth.models import UserProfile 9 | 10 | 11 | class UserProfileService(Service, CRUDMixin): 12 | service_exceptions = (ValueError,) 13 | 14 | update_form = UpdateUserProfileForm 15 | create_form = CreateUserProfileForm 16 | 17 | model = UserProfile 18 | 19 | @classmethod 20 | def update_user_basic_information( 21 | cls, user, first_name=None, last_name=None, language=None 22 | ): 23 | """ 24 | Update user basic information like name, … for the current user 25 | :param language: iso language code like en, de, … 26 | :param user: 27 | :param first_name: new first name of the user 28 | :param last_name: new last name of the user 29 | :return: updated user object 30 | """ 31 | if first_name is not None: 32 | user.first_name = first_name 33 | 34 | if last_name: 35 | user.last_name = last_name 36 | 37 | cls._update(user.profile.pk, {"language": language, "profile_setup_done": True}) 38 | user.profile.refresh_from_db() 39 | 40 | user.save() 41 | return user 42 | 43 | @classmethod 44 | def upload_profile_picture(cls, user, picture): 45 | """ 46 | Upload user profile picture for the current user 47 | :param user: current user 48 | :param picture: the new user profile picture 49 | :return: updated user object 50 | """ 51 | # https://docs.djangoproject.com/en/2.2/howto/custom-file-storage/#django.core.files.storage.get_valid_name 52 | file_name, file_extension = picture.name.rsplit(".", 1) 53 | random_suffix = get_random_string(14) 54 | picture.name = f"{random_suffix}.{file_extension}" 55 | 56 | user.profile.profile_picture = picture 57 | user.profile.save() 58 | return user 59 | 60 | @classmethod 61 | def get_available_language(cls, user): 62 | """ 63 | :param user: the user you want to retrieve the informations for 64 | :return: a list of available languages 65 | """ 66 | languages = [] 67 | for language in settings.LANGUAGES: 68 | languages.append({"language": language[1], "iso_code": language[0]}) 69 | 70 | return languages 71 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '29 23 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /claims/models.py: -------------------------------------------------------------------------------- 1 | import reversion 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models 4 | from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation 5 | from polymorphic.models import PolymorphicModel 6 | 7 | 8 | @reversion.register() 9 | class Entity(PolymorphicModel): 10 | created_at = models.DateTimeField(auto_now_add=True) 11 | claims = GenericRelation( 12 | "Claim", content_type_field="content_type", object_id_field="object_id" 13 | ) 14 | reverse_claims = GenericRelation( 15 | "RelationshipClaim", 16 | content_type_field="target_content_type", 17 | object_id_field="target_entity_id", 18 | ) 19 | 20 | 21 | @reversion.register() 22 | class ClaimType(models.Model): 23 | name = models.CharField(max_length=255) 24 | code_name = models.CharField(max_length=255, null=True, blank=True) 25 | content_type = models.ManyToManyField(ContentType) 26 | value_schema = models.JSONField(blank=True, null=True) 27 | value_template = models.TextField(blank=True, null=True) 28 | 29 | def __str__(self): 30 | return self.name 31 | 32 | 33 | @reversion.register() 34 | class Claim(PolymorphicModel): 35 | claim_type = models.ForeignKey( 36 | ClaimType, on_delete=models.CASCADE, related_name="claims" 37 | ) 38 | created_at = models.DateTimeField(auto_now_add=True) 39 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 40 | object_id = models.PositiveIntegerField() 41 | entity = GenericForeignKey("content_type", "object_id") 42 | value = models.JSONField(null=True, blank=True) 43 | 44 | def __str__(self): 45 | return "Claim" 46 | 47 | 48 | @reversion.register() 49 | class ValueClaim(Claim): 50 | def __str__(self): 51 | return f"{self.claim_type} | {self.entity}" 52 | 53 | 54 | @reversion.register() 55 | class RelationshipClaim(Claim): 56 | target_content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 57 | target_entity_id = models.PositiveIntegerField() 58 | target = GenericForeignKey("target_content_type", "target_entity_id") 59 | 60 | def __str__(self): 61 | return f"{self.claim_type} | {self.entity} -> {self.target}" 62 | 63 | 64 | @reversion.register() 65 | class ClaimSource(models.Model): 66 | claim = models.ForeignKey(Claim, related_name="sources", on_delete=models.CASCADE) 67 | url = models.URLField() 68 | comment = models.CharField(max_length=255) 69 | created_at = models.DateTimeField(auto_now_add=True) 70 | 71 | def __str__(self): 72 | return "ClaimSource" 73 | -------------------------------------------------------------------------------- /claims/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.test import TestCase 4 | from django.contrib.auth.models import User 5 | from django.contrib.contenttypes.models import ContentType 6 | 7 | from claims.models import ClaimType 8 | from claims.services import ValueClaimService, ClaimServiceException 9 | from organisation.services import OrganisationEntityService 10 | from person.services import PersonService 11 | 12 | import jsonschema.exceptions 13 | 14 | 15 | VALIDATION_JSON = """{"name": null, "type": "object", "title": "Dial Code", "required": ["dialCode"], "properties": {"dialCode": {"type": "string", "title": "dialCode", "description": "Dial Code (e.g. 1312)"}}}""" 16 | 17 | 18 | class TestValueClaimService(TestCase): 19 | def setUp(self): 20 | self.adminuser = User.objects.create_user("admin", "admin@test.com", "pass") 21 | self.adminuser.save() 22 | self.adminuser.is_superuser = True 23 | self.adminuser.save() 24 | self.person = PersonService.create_person(self.adminuser, "Dr. Test") 25 | self.test_claim_type = ClaimType( 26 | name="testclaim", 27 | code_name="testClaim", 28 | value_schema=json.loads(VALIDATION_JSON), 29 | ) 30 | self.test_claim_type.save() 31 | self.test_claim_type.content_type.set( 32 | ContentType.objects.filter(app_label="person", model="person") 33 | ) 34 | self.test_organization = OrganisationEntityService.create_organisation_entity( 35 | self.adminuser, "TestOrg" 36 | ) 37 | 38 | def test_create_claim(self): 39 | claim_value = {"dialCode": "22222"} 40 | claim = ValueClaimService.create_value_claim( 41 | self.adminuser, self.person.pk, self.test_claim_type.pk, claim_value 42 | ) 43 | self.assertEqual(claim.value, claim_value) 44 | 45 | def test_create_wrongly_formed_claim(self): 46 | claim_value = {"ac": "ab"} 47 | with self.assertRaisesMessage( 48 | jsonschema.exceptions.ValidationError, "'dialCode' is a required property" 49 | ): 50 | claim = ValueClaimService.create_value_claim( 51 | self.adminuser, self.person.pk, self.test_claim_type.pk, claim_value 52 | ) 53 | 54 | def test_create_wrong_entity_type(self): 55 | claim_value = {"dialCode": "22222"} 56 | with self.assertRaisesMessage( 57 | ClaimServiceException, 58 | "Entity Type organisation | organisation entity is not supported by claim 'testclaim'(supported: person)", 59 | ): 60 | claim = ValueClaimService.create_value_claim( 61 | self.adminuser, 62 | self.test_organization.pk, 63 | self.test_claim_type.pk, 64 | claim_value, 65 | ) 66 | -------------------------------------------------------------------------------- /person/services.py: -------------------------------------------------------------------------------- 1 | import reversion 2 | from django.contrib.auth.models import AbstractUser 3 | from serious_django_services import Service, NotPassed, CRUDMixin 4 | 5 | from person.forms import UpdatePersonForm, CreatePersonForm 6 | from person.models import Person 7 | from person.permissions import CanUpdatePersonPermission, CanCreatePersonPermission 8 | 9 | 10 | class PersonServiceException(Exception): 11 | pass 12 | 13 | 14 | class PersonService(Service, CRUDMixin): 15 | update_form = UpdatePersonForm 16 | create_form = CreatePersonForm 17 | 18 | service_exceptions = () 19 | model = Person 20 | 21 | @classmethod 22 | def retrieve_person(cls, id: int) -> Person: 23 | """ 24 | get a person by id 25 | :param id: id of the person 26 | :return: the person object 27 | """ 28 | try: 29 | person = cls.model.objects.get(pk=id) 30 | except cls.model.DoesNotExist: 31 | raise PersonServiceException("Person not found.") 32 | 33 | return person 34 | 35 | @classmethod 36 | def create_person( 37 | cls, user: AbstractUser, name: str, position: id = NotPassed 38 | ) -> Person: 39 | """create a new person 40 | :param user: the user calling the service 41 | :last_name: - Last name 42 | :first_name: - First name (Optional) 43 | :returns: the newly created person instance 44 | """ 45 | 46 | if not user.has_perm(CanCreatePersonPermission): 47 | raise PermissionError("You are not allowed to create a person.") 48 | 49 | with reversion.create_revision(): 50 | person = cls._create({"name": name, "position": position}) 51 | reversion.set_user(user) 52 | 53 | return person 54 | 55 | @classmethod 56 | def update_person( 57 | cls, 58 | user: AbstractUser, 59 | person_id: int, 60 | name: str = NotPassed, 61 | position: id = NotPassed, 62 | ) -> Person: 63 | """create a new person 64 | :param user: the user calling the service 65 | :param person_id: - ID of the exsisting entity that should be updated 66 | :param name: - name 67 | :param position: - position 68 | :return: the updated person instance 69 | """ 70 | 71 | person = cls.retrieve_person(person_id) 72 | 73 | if not user.has_perm(CanUpdatePersonPermission, person): 74 | raise PermissionError("You are not allowed to update this person.") 75 | 76 | with reversion.create_revision(): 77 | person = cls._update( 78 | person_id, 79 | {"name": name, "position": position}, 80 | ) 81 | reversion.set_user(user) 82 | reversion.set_comment(f"update via service by {user}") 83 | 84 | person.refresh_from_db() 85 | return person 86 | -------------------------------------------------------------------------------- /settings/templates/account.html: -------------------------------------------------------------------------------- 1 | {% extends "auth_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %} 5 | {% trans "Account" %} 6 | {% endblock %} 7 | 8 | {% block content %} 9 |
10 |
11 |
12 |

{% if user.first_name %}{{ user.first_name }} {{ user.last_name }}{% else %}{{ user.username }}{% endif %}

13 |
14 |
15 |
16 | {% blocktrans with member_since=user.date_joined %} 17 | Memember since:
{{ member_since }} 18 | {% endblocktrans %} 19 |
20 |
21 | {% blocktrans with last_login=user.last_login %} 22 | Last login:
{{ last_login }} 23 | {% endblocktrans %} 24 |
25 |
26 | {% trans "Account Settings:"%}
27 | {% trans "Change Password" %} 28 | 29 |
30 |


31 |

{% trans "Applications" %}

32 |
33 | {% if apps|length > 0 %} 34 | {% trans "You are currently using the following Applications:" %} 35 | {% else %} 36 | {% trans "You don't use any Applications. 😢" %} 37 | {% endif %} 38 | {% for app, meta_data in apps.items %} 39 |

{{ app.name }}

40 | {% trans "This app can:" %}
41 | {% for scope in app.scopes.all %} 42 |
  • {{ scope }}
  • 43 | {% endfor %}
    44 | {% trans "Latest application activities:" %} 45 | {% for login in meta_data.credentials %} 46 |
  • 47 | {% blocktrans with app_name=login.application.name last_login=login.created %} 48 | Authorization via {{ app_name }} ({{ last_login }}) 49 | {% endblocktrans %} 50 |
  • 51 | {% endfor %} 52 |
    53 | {% endfor %}

    54 |

    {% trans "Social Logins" %}

    55 |
    56 | {% if user.social_auth.count > 0 %} 57 | {% trans "You are currently using the following social login providers:" %} 58 | {% else %} 59 | {% trans "Your Account is not connected with any social login providers. 🤷" %} 60 | {% endif %} 61 | {% for auth_provider in user.social_auth.all %} 62 |
  • {{ auth_provider.provider }}
  • 63 | {% endfor %} 64 |
    65 |
    66 |
    67 | {% endblock %} -------------------------------------------------------------------------------- /settings/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends "auth_base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | {% load static %} 5 | 6 | {% block form_content %} 7 |
    8 |
    9 |
    10 |
    11 | {% csrf_token %} 12 | {% if form.non_field_errors %} 13 | 16 | {% endif %} 17 |
    18 | 19 | 21 |
    {{ form.username.errors }}
    22 |
    23 |
    24 | 25 | 26 | 27 | {% trans "Forgot your password?" %} 28 | 29 |
    {{ form.password.errors }}
    30 |
    31 | 32 | 33 | 34 |
    35 |
    36 | 43 | 44 |
    45 | 46 |
    47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /person/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from django_graphene_permissions import permissions_checker 3 | from django_graphene_permissions.permissions import IsAuthenticated 4 | from graphene import relay, Connection 5 | from graphene_django import DjangoObjectType 6 | from graphene_django.filter import DjangoFilterConnectionField 7 | from graphql_relay import from_global_id 8 | from serious_django_graphene import ( 9 | FailableMutation, 10 | get_user_from_info, 11 | MutationExecutionException, 12 | ) 13 | from serious_django_services import NotPassed 14 | 15 | from claims.schema import PersonNode 16 | from person.models import PositionAbbreviation, PersonPosition 17 | from person.permissions import CanCreatePersonPermission, CanUpdatePersonPermission 18 | from person.services import PersonService 19 | 20 | 21 | class PositionAbbreviationNode(DjangoObjectType): 22 | class Meta: 23 | model = PositionAbbreviation 24 | filter_fields = ["id", "name"] 25 | interfaces = (relay.Node,) 26 | 27 | 28 | class PersonPositionNode(DjangoObjectType): 29 | class Meta: 30 | model = PersonPosition 31 | filter_fields = ["id"] 32 | interfaces = (relay.Node,) 33 | 34 | 35 | class CreatePerson(FailableMutation): 36 | person = graphene.Field(PersonNode) 37 | 38 | class Arguments: 39 | name = graphene.String(required=True) 40 | position = graphene.ID(required=False) 41 | 42 | @permissions_checker([IsAuthenticated, CanCreatePersonPermission]) 43 | def mutate(self, info, name, position): 44 | user = get_user_from_info(info) 45 | try: 46 | result = PersonService.create_person( 47 | user, name, int(from_global_id(position)[1]) 48 | ) 49 | except PersonService.exceptions as e: 50 | raise MutationExecutionException(str(e)) 51 | return CreatePerson(success=True, person=result) 52 | 53 | 54 | class UpdatePerson(FailableMutation): 55 | person = graphene.Field(PersonNode) 56 | 57 | class Arguments: 58 | person_id = graphene.ID(required=True) 59 | name = graphene.String(required=True) 60 | position = graphene.ID(required=False) 61 | 62 | @permissions_checker([IsAuthenticated, CanUpdatePersonPermission]) 63 | def mutate( 64 | self, 65 | info, 66 | person_id, 67 | name=NotPassed, 68 | position=NotPassed, 69 | ): 70 | user = get_user_from_info(info) 71 | try: 72 | result = PersonService.update_person( 73 | user, 74 | int(from_global_id(person_id)[1]), 75 | name=name, 76 | position=int(from_global_id(position)[1]), 77 | ) 78 | except PersonService.exceptions as e: 79 | raise MutationExecutionException(str(e)) 80 | return UpdatePerson(success=True, person=result) 81 | 82 | 83 | class Mutation(graphene.ObjectType): 84 | create_person = CreatePerson.Field() 85 | update_person = UpdatePerson.Field() 86 | 87 | 88 | class Query(graphene.ObjectType): 89 | position_abbreviation = relay.Node.Field(PositionAbbreviationNode) 90 | all_position_abbreviations = DjangoFilterConnectionField(PositionAbbreviationNode) 91 | 92 | 93 | ## Schema 94 | schema = graphene.Schema(mutation=Mutation, query=Query) 95 | -------------------------------------------------------------------------------- /settings/static/css/auth.css: -------------------------------------------------------------------------------- 1 | /* ------ common ------- */ 2 | body { 3 | background-color: #f8f9fa; 4 | } 5 | 6 | .logo { 7 | color: black; 8 | font-weight: bold; 9 | font-size: 20px; 10 | } 11 | 12 | input { 13 | border-radius: 0!important; 14 | } 15 | 16 | h6{ 17 | font-size: 16px; 18 | color: rgba(0, 0, 0, 0.85); 19 | font-weight: 600; 20 | } 21 | 22 | .navbar-container { 23 | background: white; 24 | box-shadow: 0 2px 5px 1px #cecece; 25 | margin-bottom: 40px; 26 | width: 100%; 27 | max-width: 100% !important; 28 | } 29 | .navbar-container .col-10 { 30 | display: flex; 31 | justify-content: space-between; 32 | padding: 15px; 33 | } 34 | .navbar-icon { 35 | width: 20px; 36 | height: 20px; 37 | } 38 | 39 | .question-icon { 40 | width: 20px; 41 | height: 20px; 42 | margin-bottom: 2px; 43 | } 44 | 45 | .back-icon { 46 | margin-bottom: 2px; 47 | } 48 | 49 | .color-silver { 50 | color: silver; 51 | } 52 | 53 | .need-help{ 54 | color: #A5A5A5; 55 | font-size: 16px; 56 | } 57 | 58 | .form-footer { 59 | border-top: 2px solid #e8e8e8; 60 | padding: 20px 20px 20px 20px !important; 61 | } 62 | 63 | .need-help-container{ 64 | margin-top: 5px; 65 | } 66 | 67 | .social-login-btn { 68 | margin-bottom: 20px; 69 | width: 100%; 70 | vertical-align: middle; 71 | } 72 | 73 | a.btn-primary,.btn-primary{ 74 | background-color: #ee44aa; 75 | border: 0px solid black; 76 | color: #fff!important; 77 | border-radius: 0px; 78 | } 79 | 80 | .social-login-btns { 81 | padding-top: 50px; 82 | text-align: center; 83 | 84 | 85 | } 86 | 87 | .btn-primary:hover{ 88 | box-shadow: none; 89 | background-color: #ee44aa; 90 | color: #fff!important; 91 | } 92 | .btn-primary:active{ 93 | box-shadow: 0 0 0 0.2rem #ee44aa; 94 | background-color: #ee44aa !important; 95 | } 96 | .btn-primary:focus{ 97 | border: none; 98 | box-shadow: 0 0 0 0.2rem #ee44aa; 99 | background-color: #ee44aa !important; 100 | } 101 | .btn-primary:focus { 102 | box-shadow: 0 0 0 0.2rem #ee44aa!important; 103 | } 104 | .btn-primary:not(:disabled):not(.disabled):active { 105 | background-color: #ee44aa; 106 | border-color: #ee44aa; 107 | } 108 | 109 | 110 | a { 111 | color: #ee44aa!important; 112 | } 113 | a:hover { 114 | color: #ee44aa!important; 115 | text-decoration: none!important; 116 | } 117 | .disable-select { 118 | user-select: none; 119 | } 120 | /* --------/ common ---------- */ 121 | 122 | 123 | /* ------ login.html ------ */ 124 | 125 | 126 | .login label { 127 | font-weight: 400; 128 | font-size: 14px; 129 | margin-bottom: 0; 130 | line-height: 22px; 131 | } 132 | .login form { 133 | margin-bottom: 20px; 134 | margin-top: 30px; 135 | } 136 | .login input{ 137 | max-width: 400px; 138 | } 139 | .login .message { 140 | padding: 30px 24px 5px 24px; 141 | } 142 | .login .form-row { 143 | margin-left: 15px; 144 | margin-right: 20px; 145 | } 146 | 147 | 148 | 149 | .login .form-panel { 150 | background-color: #fff; 151 | border: 1px solid #e8e8e8; 152 | } 153 | 154 | #login-form{ 155 | margin-top: 30px; 156 | } 157 | .login .forgotPasswordLink{ 158 | display: block; 159 | font-size: 14px; 160 | margin-top: 10px; 161 | } 162 | /* ------/ login.html ------ */ 163 | 164 | .account-panel { 165 | background-color: #fff; 166 | border: 1px solid #e8e8e8; 167 | padding-top: 20px; 168 | padding-bottom: 20px; 169 | } -------------------------------------------------------------------------------- /orgcharts/signals.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import boto3 4 | from django.conf import settings 5 | from django.db.models.signals import post_save 6 | from django.dispatch import receiver 7 | from graphql_relay import to_global_id 8 | 9 | from orgcharts.models import OrgChart, OrgChartStatusChoices, OrgChartURL 10 | from orgcharts.schema import OrgChartNode, OrgChartURLNode 11 | 12 | 13 | @receiver(post_save, sender=OrgChartURL) 14 | def start_orgchart_crawling(sender, instance, created, **kwargs): 15 | client = boto3.client( 16 | "sns", 17 | region_name=settings.AWS_EB_DEFAULT_REGION, 18 | aws_access_key_id=settings.AWS_ACCESS_KEY_ID, 19 | aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, 20 | ) 21 | message = { 22 | "action": "crawl-orgchart", 23 | "parameters": { 24 | "org_chart_url_id": to_global_id(OrgChartURLNode.__name__, instance.pk) 25 | }, 26 | } 27 | print(message) 28 | response = client.publish( 29 | TopicArn=settings.ORGCHART_CRAWLER_SNS_TOPIC, Message=json.dumps(message) 30 | ) 31 | print(response) 32 | 33 | 34 | @receiver(post_save, sender=OrgChart) 35 | def start_orgchart_analysis(sender, instance, created, **kwargs): 36 | if ( 37 | instance.status == OrgChartStatusChoices.NEW 38 | and settings.ORGCHART_ANALYSIS_SNS_TOPIC is not None 39 | ): 40 | client = boto3.client( 41 | "sns", 42 | region_name=settings.AWS_EB_DEFAULT_REGION, 43 | aws_access_key_id=settings.AWS_ACCESS_KEY_ID, 44 | aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, 45 | ) 46 | message = { 47 | "action": "analyze-orgchart", 48 | "parameters": { 49 | "orgchart_id": to_global_id(OrgChartNode.__name__, instance.pk), 50 | "page": 0, # TODO: fixme 51 | }, 52 | } 53 | print("message") 54 | response = client.publish( 55 | TopicArn=settings.ORGCHART_ANALYSIS_SNS_TOPIC, Message=json.dumps(message) 56 | ) 57 | 58 | 59 | @receiver(post_save, sender=OrgChart) 60 | def start_orgchart_image_caching(sender, instance, created, **kwargs): 61 | if ( 62 | instance.status == OrgChartStatusChoices.PARSED 63 | and settings.ORGCHART_ANALYSIS_SNS_TOPIC is not None 64 | ): 65 | client = boto3.client( 66 | "sns", 67 | region_name=settings.AWS_EB_DEFAULT_REGION, 68 | aws_access_key_id=settings.AWS_ACCESS_KEY_ID, 69 | aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, 70 | ) 71 | message = { 72 | "action": "orgchart-image", 73 | "parameters": { 74 | "orgchart_id": to_global_id(OrgChartNode.__name__, instance.pk), 75 | "page": 0, # TODO: fixme 76 | }, 77 | } 78 | response = client.publish( 79 | TopicArn=settings.ORGCHART_ANALYSIS_SNS_TOPIC, 80 | Message=json.dumps(message), # TODO: fixme 81 | ) 82 | message = { 83 | "action": "cache-all-orgchart-images", 84 | "parameters": { 85 | "orgchart_id": to_global_id(OrgChartNode.__name__, instance.pk), 86 | "page": 0, # TODO: fixme 87 | }, 88 | } 89 | response = client.publish( 90 | TopicArn=settings.ORGCHART_ANALYSIS_SNS_TOPIC, 91 | Message=json.dumps(message), # TODO: fixme 92 | ) 93 | -------------------------------------------------------------------------------- /settings/configs/production.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from os import environ 3 | import os 4 | 5 | from django_secrets import SECRETS 6 | 7 | # aws access key for secret manager and s3 8 | AWS_ACCESS_KEY_ID = os.environ.get("APPLICATION_AWS_ACCESS_KEY_ID") 9 | AWS_SECRET_ACCESS_KEY = os.environ.get("APPLICATION_AWS_SECRET_ACCESS_KEY") 10 | 11 | AWS_SECRETS_MANAGER_SECRET_NAME = os.environ.get("AWS_SECRETS_MANAGER_SECRET_NAME") 12 | AWS_SECRETS_MANAGER_SECRET_SECTION = os.environ.get( 13 | "AWS_SECRETS_MANAGER_SECRET_SECTION" 14 | ) 15 | AWS_SECRETS_MANAGER_REGION_NAME = os.environ.get("AWS_REGION_NAME") 16 | 17 | DATABASES = { 18 | "default": { 19 | "ENGINE": "django.db.backends.postgresql_psycopg2", 20 | "HOST": SECRETS.get("RDS_DB_HOST"), 21 | "NAME": SECRETS.get("RDS_DB_NAME"), 22 | "USER": SECRETS.get("RDS_DB_USER"), 23 | "PASSWORD": SECRETS.get("RDS_DB_PASSWORD"), 24 | }, 25 | } 26 | 27 | ALLOWED_CIDR_NETS = ["172.16.0.0/12", "127.0.0.1/8"] 28 | import sentry_sdk 29 | from sentry_sdk.integrations.django import DjangoIntegration 30 | 31 | sentry_sdk.init( 32 | dsn=SECRETS.get("SENTRY_DSN"), 33 | integrations=[DjangoIntegration()], 34 | # Set traces_sample_rate to 1.0 to capture 100% 35 | # of transactions for performance monitoring. 36 | # We recommend adjusting this value in production. 37 | traces_sample_rate=1.0, 38 | # If you wish to associate users to errors (assuming you are using 39 | # django.contrib.auth) you may enable sending PII data. 40 | send_default_pii=True, 41 | ) 42 | 43 | # SECURITY WARNING: don't run with debug turned on in production! 44 | DEBUG = False 45 | 46 | SOCIAL_AUTH_JSONFIELD_ENABLED = True 47 | 48 | # TODO 49 | ALLOWED_HOSTS = [ 50 | os.environ.get("ALLOWED_HOSTS"), 51 | ".elasticbeanstalk.com", 52 | ".amazonaws.com", 53 | ] 54 | 55 | CORS_ALLOW_ALL_ORIGINS = False 56 | CORS_ALLOWED_ORIGIN_REGEXES = [ 57 | r"^https://[a-zA-z0-9-.]{1,}\.bund\.dev$", 58 | r"^http://127.0.0.1:[0-9]{1,}$", 59 | r"^http://localhost:[0-9]{1,}$", 60 | ] 61 | 62 | AWS_EB_DEFAULT_REGION = os.environ.get("AWS_REGION_NAME") 63 | 64 | # https://github.com/jschneier/django-storages/issues/782 65 | AWS_S3_ADDRESSING_STYLE = "virtual" 66 | 67 | SECRET_KEY = SECRETS.get("SECRET_KEY") 68 | 69 | JWT_PRIVATE_KEY_STRUKTUREN = SECRETS.get("JWT_PRIVATE_KEY") 70 | JWT_PUBLIC_KEY_STRUKTUREN = SECRETS.get("JWT_PUBLIC_KEY") 71 | 72 | USE_TZ = False 73 | 74 | STATIC_ROOT = "/static/" 75 | DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" 76 | STATICFILES_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" 77 | AWS_S3_REGION_NAME = os.environ.get("AWS_REGION_NAME") 78 | AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_BUCKET_NAME") 79 | OPEN_SEARCH_CLUSTER_ENDPOINT = os.environ.get("OPEN_SEARCH_CLUSTER_ENDPOINT") 80 | 81 | ORGCHART_CRAWLER_SNS_TOPIC = os.environ.get("ORGCHART_CRAWLER_SNS_TOPIC") 82 | ORGCHART_ANALYSIS_SNS_TOPIC = os.environ.get("ORGCHART_ANALYSIS_SNS_TOPIC") 83 | 84 | SOCIAL_AUTH_GITHUB_KEY = SECRETS.get("SOCIAL_AUTH_GITHUB_KEY") 85 | SOCIAL_AUTH_GITHUB_SECRET = SECRETS.get("SOCIAL_AUTH_GITHUB_SECRET") 86 | SOCIAL_AUTH_GITHUB_SCOPE = ["user:email"] 87 | 88 | SOCIAL_AUTH_FROIDE_KEY = SECRETS.get("SOCIAL_AUTH_FROIDE_KEY") 89 | SOCIAL_AUTH_FROIDE_SECRET = SECRETS.get("SOCIAL_AUTH_FROIDE_SECRET") 90 | 91 | from opensearchpy import AWSV4SignerAuth, OpenSearch, RequestsHttpConnection 92 | import boto3 93 | 94 | credentials = boto3.session.Session( 95 | region_name=AWS_EB_DEFAULT_REGION, 96 | aws_access_key_id=AWS_ACCESS_KEY_ID, 97 | aws_secret_access_key=AWS_SECRET_ACCESS_KEY, 98 | ).get_credentials() 99 | auth = AWSV4SignerAuth(credentials, AWS_EB_DEFAULT_REGION) 100 | 101 | OPENSEARCH_DSL = { 102 | "default": { 103 | "hosts": [{"host": OPEN_SEARCH_CLUSTER_ENDPOINT, "port": 443}], 104 | "http_auth": auth, 105 | "use_ssl": True, 106 | "verify_certs": True, 107 | "connection_class": RequestsHttpConnection, 108 | } 109 | } 110 | 111 | try: 112 | from .local import * 113 | except ImportError: 114 | pass 115 | -------------------------------------------------------------------------------- /oauth/views.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import json 3 | import logging 4 | from django.core.exceptions import ImproperlyConfigured 5 | from oauth2_provider_jwt.views import MissingIdAttribute 6 | 7 | from django.conf import settings 8 | from django.utils.module_loading import import_string 9 | from oauth2_provider import views 10 | from oauth2_provider.models import get_access_token_model 11 | 12 | from oauth2_provider_jwt.utils import generate_payload 13 | import jwt 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def encode_jwt(payload, headers=None): 19 | """ 20 | :type payload: dict 21 | :type headers: dict, None 22 | :rtype: str 23 | """ 24 | # RS256 in default, because hardcoded legacy 25 | algorithm = getattr(settings, "JWT_ENC_ALGORITHM", "RS256") 26 | 27 | private_key_name = "JWT_PRIVATE_KEY_{}".format(payload["iss"].upper()) 28 | private_key = getattr(settings, private_key_name, None) 29 | if not private_key: 30 | raise ImproperlyConfigured("Missing setting {}".format(private_key_name)) 31 | encoded = jwt.encode(payload, private_key, algorithm=algorithm, headers=headers) 32 | return encoded 33 | 34 | 35 | class TokenView(views.TokenView): 36 | def _get_access_token_jwt(self, request, content): 37 | extra_data = {} 38 | issuer = settings.JWT_ISSUER 39 | 40 | payload_enricher = getattr(settings, "JWT_PAYLOAD_ENRICHER", None) 41 | if payload_enricher: 42 | fn = import_string(payload_enricher) 43 | extra_data = fn(request) 44 | extra_data["access_token"] = content["access_token"] 45 | 46 | if "scope" in content: 47 | extra_data["scope"] = content["scope"] 48 | 49 | id_attribute = getattr(settings, "JWT_ID_ATTRIBUTE", None) 50 | if id_attribute: 51 | token = get_access_token_model().objects.get(token=content["access_token"]) 52 | self.assign_client_credentials_to_user(token) 53 | id_value = getattr(token.user, id_attribute, None) 54 | if not id_value: 55 | raise MissingIdAttribute() 56 | extra_data[id_attribute] = str(id_value) 57 | 58 | payload = generate_payload(issuer, content["expires_in"], **extra_data) 59 | token = encode_jwt(payload) 60 | return token 61 | 62 | @staticmethod 63 | def assign_client_credentials_to_user(token): 64 | if token.user is None: 65 | token.user = token.application.user 66 | token.save() 67 | return token 68 | 69 | @staticmethod 70 | def _is_jwt_config_set(): 71 | issuer = getattr(settings, "JWT_ISSUER", "") 72 | private_key_name = "JWT_PRIVATE_KEY_{}".format(issuer.upper()) 73 | private_key = getattr(settings, private_key_name, None) 74 | id_attribute = getattr(settings, "JWT_ID_ATTRIBUTE", None) 75 | if issuer and private_key and id_attribute: 76 | return True 77 | else: 78 | return False 79 | 80 | def post(self, request, *args, **kwargs): 81 | response = super(TokenView, self).post(request, *args, **kwargs) 82 | content = json.loads(response.content) 83 | if response.status_code == 200 and "access_token" in content: 84 | if not TokenView._is_jwt_config_set(): 85 | logger.warning("Missing JWT configuration, skipping token build") 86 | else: 87 | try: 88 | content["access_token"] = self._get_access_token_jwt( 89 | request, content 90 | ) 91 | try: 92 | content = bytes(json.dumps(content), "utf-8") 93 | except TypeError: 94 | content = bytes(json.dumps(content).encode("utf-8")) 95 | response.content = content 96 | except MissingIdAttribute: 97 | response.status_code = 400 98 | response.content = json.dumps( 99 | { 100 | "error": "invalid_request", 101 | "error_description": "App not configured correctly. " 102 | "Please set JWT_ID_ATTRIBUTE.", 103 | } 104 | ) 105 | return response 106 | -------------------------------------------------------------------------------- /settings/configs/test.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from os import environ 3 | import os 4 | 5 | DATABASES = { 6 | "default": { 7 | "ENGINE": "django.db.backends.sqlite3", 8 | "NAME": BASE_DIR / "db.sqlite3", 9 | } 10 | } 11 | 12 | # TODO: mock us during runtime 13 | 14 | # SECURITY WARNING: keep the secret key used in production secret! 15 | SECRET_KEY = "django-insecure-@_)sfbt%39%85=#i0-)7p04gvuu-o@a%6i@3c9tlc^-r(57&@*" 16 | 17 | JWT_PRIVATE_KEY_STRUKTUREN = """ 18 | -----BEGIN RSA PRIVATE KEY----- 19 | MIIJJwIBAAKCAgEApZDxqhI8O93V0LGwAiQqYR1n2PPeby7DbvrE3yd4zWgw5Rxe 20 | 3SnmzXH2t+Q0O3OSsH5y19ZeDgfyRX74REr+bPW0Mkg99POAJWmXZF6XyWEKWETJ 21 | 4I+/sK5jySOMOA6Bc+eSPoPtQTjUdI7zLk/XXPlzid6LveRqPinj4LpO4yHmN6tF 22 | RQPG6dZ/gIshCONoS/YZLeYUhQIGSuL7zNJTldcXhKRXcZ5iqW+v4DlNtuzCLGlZ 23 | tLnu33H9cHmW9kWxbdMB0a6Jf/x8I8X3g3Cbu1pcDduDrRtDgkrYq4UDFT07UlpR 24 | iGZ3lHtK/48aYu0CnsglVEIpku4S85IAOYWTz9D1yjIkTWXauaywGXsUPcf2/mlE 25 | MiclXonynoY0ixR9IxbWCBr2XxeiyCaYx0xzP9dPrVIyhZRgbZXfM5CHRexlUWwW 26 | AifvfJfEjDVq76SqwYr70Ta1aMRIYMb90ojmaHyRKOR+KGHAJb2T32sC9FNMb0EL 27 | ksN1vZRahYhUtDBUZLvf9iBVXEGOiVCj1J12AvoGz6mX8IYWqpD9ldl+i17PeD+r 28 | W5+TgsNj/IzY2AnvIp4+rcozzqaOt10KvdItEO9JWOEdqNI8fJ+M6MREFLg+driU 29 | tw4CODTozIIfl6GpJzbHxSWcKdmp348IogXHFd8TQgdXEtO1jQ3Npo82IIcCAwEA 30 | AQKCAgAC1aJtiPZjB/87HW+n+bqIAxreCf7K5IAQDFcGgwR8b8Y2he/R1X/QEJ1q 31 | tIt4YRgn0WJh85eUoeox6mSRtr74WpSFL9tvsCOHgHFJFJ2AoxqsPDFAmPVtLu8i 32 | aGtkIktxEovcaiHLtg9dF31uU4uaWeLyf07hJ2HyQoFWPZpQJSpt1Y7QCaqEIln4 33 | d2lPX6VPd50ivgen50r4ST6KWSd5Lz+F09JzbYS+5dya+CAue4sve3Y/s9c1GByA 34 | qnQ9LyBEgxJK5rQP7uCpNCByraDc6kUdL57nfcoAFwvyk8pjuLKlTEqNDUQK1LmJ 35 | +oc3HlunIEITWTag/1ZvuRYr5e+L3jhM46GgxLgNolNUxUHrxBPLl/FoFTe3vL6E 36 | hzHYjZylLX+XA3t3NHphCsCR1UYJdyxiqfYqrkDNkfcMMooIUdc2biQtaM6OB51u 37 | F1p/e2VcWFzJnN7aCmMYjv2KitiVkkT3koIDr4VinUB8G+KUgcUqeI0QPO8Q99iA 38 | ROhxNaky/Q/GjZR4Re6kFh6olF920QG0rCUckslferMpKrII7ZEJ4kuW90PFY6Py 39 | kjMiOXkZmhUFzIFh8wzqAfbqqqCjiJgplEwax2UATv6ykSDAMbCkfPDTTUTr9Xji 40 | kTrDUkMhEg/wNTE9c+Icf4HQl+OO3IRtsfKrGowEmBkG9veJsQKCAQEA1PV3Z4Bo 41 | 2L31mYxNh6deEZU4whY6hDLOMvU4zoyCbeKbVl7RzF1/xZqLtTmmDqPkgbyHyvP9 42 | lAipmG0sAifsuoaVPk1bvFf9i1fi4YW8yFRo5fJRjstja5Cid6d+JGECMdPxbbKv 43 | JxfRGayPDPWaiDwTgTClZOk3SdWs5xUCEtGwnJ3Yt2hZoa+JWlpH5SEU8fsZSCoJ 44 | 5eLixaoadJOf8kMv2tTO7mBPpLdu/N+D4IYsMT4EK+C96XIJCB7nVcz6rrowCmB9 45 | o8RE3X8GD0tS3u0vVe5IfQbptLF+smuuKANFbAtboD2F7KF69QMppYaBPGh+9qTF 46 | RSKHOSB6cinuLQKCAQEAxwdfIMSoPJx1Cyl6lxbZx9KPaOQJSncjLAltimUC150W 47 | jLm4HakksfS5N/Z/R+D8JFNMPKxcGRoznXnAAcVesx4aVG7bITfuz2ZXAojUbM0M 48 | 9RemhZVkb7stol28za0BP6oWRZsPAr2/Ro/YRMwBpFnFrp733ZEcNwSXgoM0mf6t 49 | N7nCv2UO2odhcVNt6SYc7QDVcegIatL6bhbSIRtgVb08OZtlQMZxxWKUX6un/NgP 50 | td9gtrJVi6Gv9OGnlotmvamWQK3dQ4LH9WPGdFQtcRV1kc79YBqDWFKss5AxV1IR 51 | 5A9m+zpxeTmW4oGRiqf+8VFF8goiYoLKe1KTs4VuAwKCAQAPaYBtvi5YWU8YAL5v 52 | rd4x+ZG1AjTT8nVX3MVytVqPJ1JEqvIWD0I7A9dOk1CASL414XYWaxgUCZh0jpob 53 | wdXxHeJZMvILrHaOChtCZRJnkSxST/o1EmUsmLgZXsbTTS4CeytC3Cau9ptMd1+W 54 | +YNojqh+tg2SQwqcTlmIE84lnIVioE3Z4DR0bibLojMH0yAX7ytCPMCgoY317jyh 55 | 6TkvKEujU7lyKQg6jIf8xxRdQHicS7ezkT1NUtJygwINBJuz34ewiJEvM/oj6Zh/ 56 | rNzfg1zkpC0c1048pIfd08sz3CC/FAdajnlNydYDO2pdL2HVBF8D7KLWQQx2RvJ1 57 | prE1AoIBAH5DzfTy7ixtsc9gBDbgN0+O5I5NxRsp0/V3Ebhv9sqlDQ5AMG8YxH/l 58 | WrAHQJ5wPGYrNj1zt4XxWnd4Kvi0pyyJV3jjTz+WxXlsWpzwA5v2xlajJ3Ct4ycD 59 | H6NXRpVRQW6LUE/eXDqH+FYiobibmBsVHNV4YpV9HuJEln4lEPT1XhzxS3yy9yZq 60 | JsaHgD4egNFW6xK1esmSiW/YKHz6ajZatF9zl1vtyXXI4YqEUzGUPPtL+IZPQvgv 61 | nnqDwhc+3vJKKVllM+9Fg+fI4bkhQibwz0Kuh441o8gfwxKz0qmsFk+R+eo+HIkk 62 | oPWX76aAh7u+rNot1bybbyunqq6EYtMCggEASBvl/hjmmc5AJFtLr8K8r8z54KOI 63 | aVKLMQJsVzXy1k/Wn9cZ/rUt74ZFv+Rzleek6vUgnH7hJR7Jc0jT9Zy/mJLf0dV8 64 | rJ6+xUR/3WbwpRmRxQ5R9yJSEVOtnwh33Qapp2CjpuxjiNmInaVO+F2OW5Nfdnkx 65 | IrGFpZcsWDkZjzP2BJ6q8ae7OPFnS7y/mdR2jP70hRzK3s80hUOhE4jTW4Bx3s9h 66 | zk4Pp0F18eXoTAJ6w+YEavx3ZXjHGX01rOFkO6oV0d1/8YK8+coChdpLN5zeCeYs 67 | ch3Z68wWkLXmLCNvfEPgbB0J83avh8DpwMQamRgh3auUvO1Phuyilge1Bw== 68 | -----END RSA PRIVATE KEY----- 69 | """ 70 | 71 | 72 | JWT_PUBLIC_KEY_STRUKTUREN = """ 73 | -----BEGIN RSA PUBLIC KEY----- 74 | MIICCgKCAgEApZDxqhI8O93V0LGwAiQqYR1n2PPeby7DbvrE3yd4zWgw5Rxe3Snm 75 | zXH2t+Q0O3OSsH5y19ZeDgfyRX74REr+bPW0Mkg99POAJWmXZF6XyWEKWETJ4I+/ 76 | sK5jySOMOA6Bc+eSPoPtQTjUdI7zLk/XXPlzid6LveRqPinj4LpO4yHmN6tFRQPG 77 | 6dZ/gIshCONoS/YZLeYUhQIGSuL7zNJTldcXhKRXcZ5iqW+v4DlNtuzCLGlZtLnu 78 | 33H9cHmW9kWxbdMB0a6Jf/x8I8X3g3Cbu1pcDduDrRtDgkrYq4UDFT07UlpRiGZ3 79 | lHtK/48aYu0CnsglVEIpku4S85IAOYWTz9D1yjIkTWXauaywGXsUPcf2/mlEMicl 80 | XonynoY0ixR9IxbWCBr2XxeiyCaYx0xzP9dPrVIyhZRgbZXfM5CHRexlUWwWAifv 81 | fJfEjDVq76SqwYr70Ta1aMRIYMb90ojmaHyRKOR+KGHAJb2T32sC9FNMb0ELksN1 82 | vZRahYhUtDBUZLvf9iBVXEGOiVCj1J12AvoGz6mX8IYWqpD9ldl+i17PeD+rW5+T 83 | gsNj/IzY2AnvIp4+rcozzqaOt10KvdItEO9JWOEdqNI8fJ+M6MREFLg+driUtw4C 84 | ODTozIIfl6GpJzbHxSWcKdmp348IogXHFd8TQgdXEtO1jQ3Npo82IIcCAwEAAQ== 85 | -----END RSA PUBLIC KEY----- 86 | """ 87 | 88 | ML_BACKEND_BASE_URL = "http://127.0.0.1:8090" 89 | -------------------------------------------------------------------------------- /oauth/schema.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.models import Group as DjGroup 3 | 4 | import graphene 5 | from django_graphene_permissions import permissions_checker 6 | from django_graphene_permissions.permissions import ( 7 | IsAuthenticated, 8 | PermissionDjangoObjectType, 9 | ) 10 | from graphene import relay, ObjectType, Field 11 | from graphene_django.filter import DjangoFilterConnectionField 12 | from graphene_django.types import DjangoObjectType 13 | from graphene_file_upload.scalars import Upload 14 | from serious_django_graphene import ( 15 | get_user_from_info, 16 | FailableMutation, 17 | MutationExecutionException, 18 | ) 19 | 20 | from oauth.services import UserProfileService 21 | 22 | 23 | class Group(DjangoObjectType): 24 | """Group Node""" 25 | 26 | class Meta: 27 | model = DjGroup 28 | fields = ("id", "name") 29 | filter_fields = {} 30 | 31 | 32 | class UserType(PermissionDjangoObjectType): 33 | """ 34 | User Node 35 | """ 36 | 37 | language = graphene.String() 38 | profile_picture = graphene.String() 39 | 40 | def resolve_language(self, info): 41 | return self.profile.language 42 | 43 | def resolve_profile_picture(self, info): 44 | if self.profile.profile_picture: 45 | return self.profile.profile_picture.url 46 | 47 | class Meta: 48 | model = get_user_model() 49 | filter_fields = {} 50 | interfaces = (relay.Node,) 51 | exclude_fields = ( 52 | "email", 53 | "password", 54 | "is_superuser", 55 | "is_staff", 56 | "last_login", 57 | "date_joined", 58 | "is_active", 59 | "username", 60 | ) 61 | 62 | @classmethod 63 | @permissions_checker([IsAuthenticated]) 64 | def get_node(cls, info, id): 65 | try: 66 | item = cls._meta.model.objects.filter(id=id).get() 67 | except cls._meta.model.DoesNotExist: 68 | return None 69 | return item 70 | 71 | 72 | class LanguageType(ObjectType): 73 | """Language object""" 74 | 75 | language = graphene.String() 76 | iso_code = graphene.String() 77 | 78 | 79 | class UserQuery(object): 80 | """ 81 | what is an abstract type? 82 | http://docs.graphene-python.org/en/latest/types/abstracttypes/ 83 | """ 84 | 85 | user = relay.Node.Field(UserType) 86 | 87 | 88 | class Query(ObjectType): 89 | 90 | me = graphene.Field(UserType) 91 | get_available_languages = graphene.List(LanguageType) 92 | 93 | @permissions_checker([IsAuthenticated]) 94 | def resolve_me(self, info, **kwargs): 95 | user = get_user_from_info(info) 96 | if user.is_authenticated: 97 | return get_user_from_info(info) 98 | return None 99 | 100 | @permissions_checker([IsAuthenticated]) 101 | def resolve_all_users(self, info, **kwargs): 102 | user = get_user_from_info(info) 103 | return get_user_model().objects.all() 104 | 105 | def resolve_get_available_languages(self, info, **kwargs): 106 | user = get_user_from_info(info) 107 | return UserProfileService.get_available_language(user) 108 | 109 | 110 | class UpdateMyUserProfile(FailableMutation): 111 | class Arguments: 112 | first_name = graphene.String() 113 | last_name = graphene.String() 114 | language = graphene.String() 115 | 116 | me = graphene.Field(UserType) 117 | 118 | @permissions_checker([IsAuthenticated]) 119 | def mutate(self, info, **kwargs): 120 | user = get_user_from_info(info) 121 | 122 | try: 123 | user = UserProfileService.update_user_basic_information(user=user, **kwargs) 124 | except UserProfileService.exceptions as e: 125 | raise MutationExecutionException(str(e)) 126 | return UpdateMyUserProfile(me=user, success=True) 127 | 128 | 129 | class UploadProfilePicture(FailableMutation): 130 | class Arguments: 131 | profile_picture = Upload(required=True) 132 | 133 | me = graphene.Field(UserType) 134 | 135 | @permissions_checker([IsAuthenticated]) 136 | def mutate(self, info, **kwargs): 137 | user = get_user_from_info(info) 138 | file_ = kwargs["profile_picture"] 139 | 140 | try: 141 | user = UserProfileService.upload_profile_picture(user=user, picture=file_) 142 | except UserProfileService.exceptions as e: 143 | raise MutationExecutionException(str(e)) 144 | return UploadProfilePicture(me=user, success=True) 145 | 146 | 147 | class Mutation(graphene.ObjectType): 148 | update_my_user_profile = UpdateMyUserProfile.Field() 149 | upload_my_profile_picture = UploadProfilePicture.Field() 150 | 151 | 152 | schema = graphene.Schema(query=Query, mutation=Mutation) 153 | -------------------------------------------------------------------------------- /settings/configs/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for settings project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.9. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | import os 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent.parent 17 | 18 | # SECURITY WARNING: don't run with debug turned on in production! 19 | DEBUG = True 20 | 21 | ALLOWED_HOSTS = [] 22 | 23 | ORGCHART_CRAWLER_SNS_TOPIC = None 24 | ORGCHART_ANALYSIS_SNS_TOPIC = None 25 | 26 | TESTING = False 27 | 28 | # Application definition 29 | 30 | INSTALLED_APPS = [ 31 | "polymorphic", 32 | "django.contrib.admin", 33 | "django.contrib.auth", 34 | "django.contrib.contenttypes", 35 | "django.contrib.sessions", 36 | "django.contrib.messages", 37 | "django.contrib.staticfiles", 38 | "reversion", 39 | "django_admin_json_editor", 40 | "graphene_django", 41 | # Serious Django 42 | "serious_django_services", 43 | "serious_django_permissions", 44 | "oauth2_provider", 45 | "oauth2_provider_jwt", 46 | "crispy_forms", 47 | "social_django", 48 | # cors 49 | "settings", 50 | "corsheaders", 51 | "organisation", 52 | "person", 53 | "claims", 54 | "orgcharts", 55 | "oauth", 56 | "django_opensearch_dsl", 57 | ] 58 | 59 | MIDDLEWARE = [ 60 | "allow_cidr.middleware.AllowCIDRMiddleware", 61 | "django.middleware.security.SecurityMiddleware", 62 | "django.contrib.sessions.middleware.SessionMiddleware", 63 | "corsheaders.middleware.CorsMiddleware", 64 | "django.middleware.common.CommonMiddleware", 65 | "django.middleware.csrf.CsrfViewMiddleware", 66 | "django.contrib.auth.middleware.AuthenticationMiddleware", 67 | "django.contrib.messages.middleware.MessageMiddleware", 68 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 69 | "corsheaders.middleware.CorsPostCsrfMiddleware", 70 | "oauth2_provider.middleware.OAuth2TokenMiddleware", 71 | ] 72 | 73 | AUTHENTICATION_BACKENDS = ( 74 | "oauth.oauth_backend.OAuth2Backend", 75 | "django.contrib.auth.backends.ModelBackend", 76 | "serious_django_permissions.permissions.PermissionModelBackend", 77 | "guardian.backends.ObjectPermissionBackend", 78 | # social auth providers 79 | "social_core.backends.github.GithubOAuth2", 80 | ) 81 | 82 | 83 | # Serious Django configuration 84 | DEFAULT_GROUPS_MODULE = "settings.default_groups" 85 | 86 | OAUTH2_PROVIDER = { 87 | "SCOPES": { 88 | "administrative-staff": "Administrative Staff", 89 | }, 90 | } 91 | 92 | GRAPHENE = { 93 | "SCHEMA": "settings.schema.schema", 94 | "RELAY_CONNECTION_MAX_LIMIT": 1000, 95 | } 96 | 97 | ROOT_URLCONF = "settings.urls" 98 | 99 | TEMPLATES = [ 100 | { 101 | "BACKEND": "django.template.backends.django.DjangoTemplates", 102 | "DIRS": [str(BASE_DIR.joinpath("settings/templates"))], 103 | "APP_DIRS": True, 104 | "OPTIONS": { 105 | "context_processors": [ 106 | "django.template.context_processors.debug", 107 | "django.template.context_processors.request", 108 | "django.contrib.auth.context_processors.auth", 109 | "django.contrib.messages.context_processors.messages", 110 | ], 111 | }, 112 | }, 113 | ] 114 | 115 | STATICFILES_DIRS = ("settings/static/",) 116 | 117 | 118 | # TODO: move this to config 119 | JWT_ISSUER = "STRUKTUREN" 120 | JWT_ENABLED = True 121 | JWT_ID_ATTRIBUTE = "email" 122 | 123 | WSGI_APPLICATION = "settings.wsgi.application" 124 | 125 | 126 | # claims that are used in the code 127 | CLAIMS = {"LEADS": "leads", "DIAL_CODE": "dialCode"} 128 | 129 | # Base url to serve media files 130 | MEDIA_URL = "/media/" 131 | 132 | # Path where media is stored 133 | MEDIA_ROOT = os.path.join(BASE_DIR, "dev_media/") 134 | 135 | 136 | # Password validation 137 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 138 | 139 | AUTH_PASSWORD_VALIDATORS = [ 140 | { 141 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 142 | }, 143 | { 144 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 145 | }, 146 | { 147 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 148 | }, 149 | { 150 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 151 | }, 152 | ] 153 | 154 | 155 | # Internationalization 156 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 157 | 158 | CORS_ALLOW_ALL_ORIGINS = True 159 | 160 | LANGUAGE_CODE = "de-DE" 161 | 162 | from django.utils.translation import ugettext_lazy as _ 163 | 164 | LANGUAGES = ( 165 | ("en-US", _("English")), 166 | ("de-DE", _("German")), 167 | ) 168 | 169 | TIME_ZONE = "UTC" 170 | 171 | USE_I18N = True 172 | 173 | USE_L10N = True 174 | 175 | USE_TZ = True 176 | 177 | 178 | # Static files (CSS, JavaScript, Images) 179 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 180 | 181 | STATIC_URL = "/static/" 182 | CRISPY_TEMPLATE_PACK = "bootstrap4" 183 | 184 | LOGIN_URL = "/auth/login/" 185 | 186 | 187 | # Default primary key field type 188 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 189 | 190 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 191 | -------------------------------------------------------------------------------- /claims/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-03 13:24 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ("contenttypes", "0002_remove_content_type_name"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Claim", 18 | fields=[ 19 | ( 20 | "id", 21 | models.BigAutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("created_at", models.DateTimeField(auto_now_add=True)), 29 | ], 30 | ), 31 | migrations.CreateModel( 32 | name="ValueClaim", 33 | fields=[ 34 | ( 35 | "claim_ptr", 36 | models.OneToOneField( 37 | auto_created=True, 38 | on_delete=django.db.models.deletion.CASCADE, 39 | parent_link=True, 40 | primary_key=True, 41 | serialize=False, 42 | to="claims.claim", 43 | ), 44 | ), 45 | ("value", models.JSONField()), 46 | ], 47 | bases=("claims.claim",), 48 | ), 49 | migrations.CreateModel( 50 | name="Entity", 51 | fields=[ 52 | ( 53 | "id", 54 | models.BigAutoField( 55 | auto_created=True, 56 | primary_key=True, 57 | serialize=False, 58 | verbose_name="ID", 59 | ), 60 | ), 61 | ("created_at", models.DateTimeField(auto_now_add=True)), 62 | ( 63 | "polymorphic_ctype", 64 | models.ForeignKey( 65 | editable=False, 66 | null=True, 67 | on_delete=django.db.models.deletion.CASCADE, 68 | related_name="polymorphic_claims.entity_set+", 69 | to="contenttypes.contenttype", 70 | ), 71 | ), 72 | ], 73 | options={ 74 | "abstract": False, 75 | "base_manager_name": "objects", 76 | }, 77 | ), 78 | migrations.CreateModel( 79 | name="ClaimType", 80 | fields=[ 81 | ( 82 | "id", 83 | models.BigAutoField( 84 | auto_created=True, 85 | primary_key=True, 86 | serialize=False, 87 | verbose_name="ID", 88 | ), 89 | ), 90 | ("name", models.CharField(max_length=255)), 91 | ("value_schema", models.JSONField()), 92 | ( 93 | "content_type", 94 | models.ForeignKey( 95 | on_delete=django.db.models.deletion.CASCADE, 96 | to="contenttypes.contenttype", 97 | ), 98 | ), 99 | ], 100 | ), 101 | migrations.CreateModel( 102 | name="ClaimSource", 103 | fields=[ 104 | ( 105 | "id", 106 | models.BigAutoField( 107 | auto_created=True, 108 | primary_key=True, 109 | serialize=False, 110 | verbose_name="ID", 111 | ), 112 | ), 113 | ("url", models.URLField()), 114 | ("comment", models.CharField(max_length=255)), 115 | ("created_at", models.DateTimeField(auto_now_add=True)), 116 | ( 117 | "claim", 118 | models.ForeignKey( 119 | on_delete=django.db.models.deletion.CASCADE, 120 | related_name="sources", 121 | to="claims.claim", 122 | ), 123 | ), 124 | ], 125 | ), 126 | migrations.AddField( 127 | model_name="claim", 128 | name="claim_type", 129 | field=models.ForeignKey( 130 | on_delete=django.db.models.deletion.CASCADE, 131 | related_name="claims", 132 | to="claims.claimtype", 133 | ), 134 | ), 135 | migrations.CreateModel( 136 | name="RelationshipClaim", 137 | fields=[ 138 | ( 139 | "claim_ptr", 140 | models.OneToOneField( 141 | auto_created=True, 142 | on_delete=django.db.models.deletion.CASCADE, 143 | parent_link=True, 144 | primary_key=True, 145 | serialize=False, 146 | to="claims.claim", 147 | ), 148 | ), 149 | ("target_entity_id", models.PositiveIntegerField()), 150 | ( 151 | "target_content_type", 152 | models.ForeignKey( 153 | on_delete=django.db.models.deletion.CASCADE, 154 | to="contenttypes.contenttype", 155 | ), 156 | ), 157 | ], 158 | bases=("claims.claim",), 159 | ), 160 | ] 161 | -------------------------------------------------------------------------------- /oauth/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.5 on 2021-02-04 17:53 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="UserProfile", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("profile_picture", models.ImageField(upload_to="")), 30 | ("profile_setup_done", models.BooleanField(default=False)), 31 | ( 32 | "language", 33 | models.CharField( 34 | choices=[ 35 | ("af", "Afrikaans"), 36 | ("ar", "Arabic"), 37 | ("ar-dz", "Algerian Arabic"), 38 | ("ast", "Asturian"), 39 | ("az", "Azerbaijani"), 40 | ("bg", "Bulgarian"), 41 | ("be", "Belarusian"), 42 | ("bn", "Bengali"), 43 | ("br", "Breton"), 44 | ("bs", "Bosnian"), 45 | ("ca", "Catalan"), 46 | ("cs", "Czech"), 47 | ("cy", "Welsh"), 48 | ("da", "Danish"), 49 | ("de", "German"), 50 | ("dsb", "Lower Sorbian"), 51 | ("el", "Greek"), 52 | ("en", "English"), 53 | ("en-au", "Australian English"), 54 | ("en-gb", "British English"), 55 | ("eo", "Esperanto"), 56 | ("es", "Spanish"), 57 | ("es-ar", "Argentinian Spanish"), 58 | ("es-co", "Colombian Spanish"), 59 | ("es-mx", "Mexican Spanish"), 60 | ("es-ni", "Nicaraguan Spanish"), 61 | ("es-ve", "Venezuelan Spanish"), 62 | ("et", "Estonian"), 63 | ("eu", "Basque"), 64 | ("fa", "Persian"), 65 | ("fi", "Finnish"), 66 | ("fr", "French"), 67 | ("fy", "Frisian"), 68 | ("ga", "Irish"), 69 | ("gd", "Scottish Gaelic"), 70 | ("gl", "Galician"), 71 | ("he", "Hebrew"), 72 | ("hi", "Hindi"), 73 | ("hr", "Croatian"), 74 | ("hsb", "Upper Sorbian"), 75 | ("hu", "Hungarian"), 76 | ("hy", "Armenian"), 77 | ("ia", "Interlingua"), 78 | ("id", "Indonesian"), 79 | ("ig", "Igbo"), 80 | ("io", "Ido"), 81 | ("is", "Icelandic"), 82 | ("it", "Italian"), 83 | ("ja", "Japanese"), 84 | ("ka", "Georgian"), 85 | ("kab", "Kabyle"), 86 | ("kk", "Kazakh"), 87 | ("km", "Khmer"), 88 | ("kn", "Kannada"), 89 | ("ko", "Korean"), 90 | ("ky", "Kyrgyz"), 91 | ("lb", "Luxembourgish"), 92 | ("lt", "Lithuanian"), 93 | ("lv", "Latvian"), 94 | ("mk", "Macedonian"), 95 | ("ml", "Malayalam"), 96 | ("mn", "Mongolian"), 97 | ("mr", "Marathi"), 98 | ("my", "Burmese"), 99 | ("nb", "Norwegian Bokmål"), 100 | ("ne", "Nepali"), 101 | ("nl", "Dutch"), 102 | ("nn", "Norwegian Nynorsk"), 103 | ("os", "Ossetic"), 104 | ("pa", "Punjabi"), 105 | ("pl", "Polish"), 106 | ("pt", "Portuguese"), 107 | ("pt-br", "Brazilian Portuguese"), 108 | ("ro", "Romanian"), 109 | ("ru", "Russian"), 110 | ("sk", "Slovak"), 111 | ("sl", "Slovenian"), 112 | ("sq", "Albanian"), 113 | ("sr", "Serbian"), 114 | ("sr-latn", "Serbian Latin"), 115 | ("sv", "Swedish"), 116 | ("sw", "Swahili"), 117 | ("ta", "Tamil"), 118 | ("te", "Telugu"), 119 | ("tg", "Tajik"), 120 | ("th", "Thai"), 121 | ("tk", "Turkmen"), 122 | ("tr", "Turkish"), 123 | ("tt", "Tatar"), 124 | ("udm", "Udmurt"), 125 | ("uk", "Ukrainian"), 126 | ("ur", "Urdu"), 127 | ("uz", "Uzbek"), 128 | ("vi", "Vietnamese"), 129 | ("zh-hans", "Simplified Chinese"), 130 | ("zh-hant", "Traditional Chinese"), 131 | ], 132 | default="en-us", 133 | max_length=20, 134 | ), 135 | ), 136 | ( 137 | "user", 138 | models.OneToOneField( 139 | on_delete=django.db.models.deletion.CASCADE, 140 | related_name="profile", 141 | to=settings.AUTH_USER_MODEL, 142 | ), 143 | ), 144 | ], 145 | ), 146 | ] 147 | -------------------------------------------------------------------------------- /orgcharts/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from django.http import request 3 | from django_graphene_permissions import permissions_checker 4 | from django_graphene_permissions.permissions import IsAuthenticated 5 | from graphene import relay 6 | from graphene_django import DjangoObjectType 7 | from graphene_django.filter import DjangoFilterConnectionField 8 | from graphql_relay import from_global_id 9 | from graphql_relay.connection.connectiontypes import Connection 10 | from graphene_file_upload.scalars import Upload 11 | from serious_django_graphene import ( 12 | FailableMutation, 13 | get_user_from_info, 14 | MutationExecutionException, 15 | ) 16 | from serious_django_services import NotPassed 17 | 18 | from orgcharts.models import OrgChartURL, OrgChart, OrgChartError, OrgChartStatusChoices 19 | from orgcharts.permissions import ( 20 | CanCreateOrgChartURLPermission, 21 | CanImportOrgChartPermission, 22 | ) 23 | from orgcharts.services import ( 24 | OrgChartURLService, 25 | OrgChartService, 26 | OrgChartImportService, 27 | OrgChartErrorService, 28 | ) 29 | 30 | OrgChartStatus = graphene.Enum.from_enum(OrgChartStatusChoices) 31 | 32 | 33 | class OrgChartURLNode(DjangoObjectType): 34 | class Meta: 35 | model = OrgChartURL 36 | filter_fields = ["id"] 37 | interfaces = (relay.Node,) 38 | 39 | 40 | class OrgChartNode(DjangoObjectType): 41 | class Meta: 42 | model = OrgChart 43 | filter_fields = ["id"] 44 | interfaces = (relay.Node,) 45 | 46 | def resolve_document(self, info): 47 | return self.document.url 48 | 49 | 50 | class OrgChartErrorNode(DjangoObjectType): 51 | class Meta: 52 | model = OrgChartError 53 | filter_fields = ["id"] 54 | interfaces = (relay.Node,) 55 | 56 | 57 | class CreateOrgChartURL(FailableMutation): 58 | org_chart_url = graphene.Field(OrgChartURLNode) 59 | 60 | class Arguments: 61 | url = graphene.String(required=True) 62 | entity_id = graphene.ID(required=True) 63 | 64 | @permissions_checker([IsAuthenticated, CanCreateOrgChartURLPermission]) 65 | def mutate(self, info, url, entity_id): 66 | user = get_user_from_info(info) 67 | try: 68 | result = OrgChartURLService.create_orgchart_url( 69 | user, url=url, entity_id=int(from_global_id(entity_id)[1]) 70 | ) 71 | except OrgChartURLService.exceptions as e: 72 | raise MutationExecutionException(str(e)) 73 | return CreateOrgChartURL(success=True, organisation_entity=result) 74 | 75 | 76 | class CreateOrgChartError(FailableMutation): 77 | org_chart_error = graphene.Field(OrgChartErrorNode) 78 | 79 | class Arguments: 80 | message = graphene.String(required=True) 81 | org_chart_url_id = graphene.ID(required=True) 82 | 83 | @permissions_checker([IsAuthenticated, CanCreateOrgChartURLPermission]) 84 | def mutate(self, info, message, org_chart_url_id): 85 | user = get_user_from_info(info) 86 | try: 87 | result = OrgChartErrorService.create_orgchart_error( 88 | user, 89 | org_chart_url_id=int(from_global_id(org_chart_url_id)[1]), 90 | message=message, 91 | ) 92 | except OrgChartErrorService.exceptions as e: 93 | raise MutationExecutionException(str(e)) 94 | return CreateOrgChartError(success=True, org_chart_error=result) 95 | 96 | 97 | class CreateOrgChart(FailableMutation): 98 | org_chart = graphene.Field(OrgChartNode) 99 | 100 | class Arguments: 101 | document_hash = graphene.String(required=True) 102 | org_chart_url_id = graphene.ID(required=True) 103 | document = Upload(required=True) 104 | 105 | @permissions_checker([IsAuthenticated, CanCreateOrgChartURLPermission]) 106 | def mutate(self, info, document_hash, org_chart_url_id, document): 107 | user = get_user_from_info(info) 108 | try: 109 | result = OrgChartService.create_orgchart( 110 | user, 111 | org_chart_url_id=int(from_global_id(org_chart_url_id)[1]), 112 | document_hash=document_hash, 113 | document=document, 114 | ) 115 | except OrgChartService.exceptions as e: 116 | raise MutationExecutionException(str(e)) 117 | return CreateOrgChart(success=True, org_chart=result) 118 | 119 | 120 | class ImportOrgChart(FailableMutation): 121 | org_chart = graphene.Field(OrgChartNode) 122 | 123 | class Arguments: 124 | orgchart_id = graphene.ID(required=True) 125 | raw_json = graphene.JSONString(required=True) 126 | 127 | @permissions_checker([IsAuthenticated, CanImportOrgChartPermission]) 128 | def mutate(self, info, orgchart_id, raw_json): 129 | user = get_user_from_info(info) 130 | try: 131 | result = OrgChartImportService.import_parsed_orgchart( 132 | user, orgchart_id=int(from_global_id(orgchart_id)[1]), orgchart=raw_json 133 | ) 134 | except OrgChartImportService.exceptions as e: 135 | raise MutationExecutionException(str(e)) 136 | return ImportOrgChart(success=True, org_chart=result) 137 | 138 | 139 | class UpdateOrgChart(FailableMutation): 140 | org_chart = graphene.Field(OrgChartNode) 141 | 142 | class Arguments: 143 | org_chart_id = graphene.ID(required=True) 144 | raw_source = graphene.JSONString(required=True) 145 | status = OrgChartStatus(required=True) 146 | 147 | @permissions_checker([IsAuthenticated, CanImportOrgChartPermission]) 148 | def mutate(self, info, org_chart_id, raw_source, status): 149 | user = get_user_from_info(info) 150 | try: 151 | result = OrgChartService.update_orgchart( 152 | user, 153 | org_chart_id=int(from_global_id(org_chart_id)[1]), 154 | raw_source=raw_source, 155 | status=status, 156 | ) 157 | except OrgChartService.exceptions as e: 158 | raise MutationExecutionException(str(e)) 159 | return UpdateOrgChart(success=True, org_chart=result) 160 | 161 | 162 | class Query(graphene.ObjectType): 163 | all_org_chart_urls = DjangoFilterConnectionField(OrgChartURLNode) 164 | org_chart_url = relay.Node.Field(OrgChartURLNode) 165 | all_org_charts = DjangoFilterConnectionField(OrgChartNode) 166 | org_chart = relay.Node.Field(OrgChartNode) 167 | 168 | 169 | class Mutation(graphene.ObjectType): 170 | create_org_chart_url = CreateOrgChartURL.Field() 171 | import_org_chart = ImportOrgChart.Field() 172 | create_org_chart_error = CreateOrgChartError.Field() 173 | create_org_chart = CreateOrgChart.Field() 174 | update_org_chart = UpdateOrgChart.Field() 175 | 176 | 177 | schema = graphene.Schema(query=Query, mutation=Mutation) 178 | -------------------------------------------------------------------------------- /organisation/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from django_graphene_permissions import permissions_checker 3 | from django_graphene_permissions.permissions import IsAuthenticated 4 | from graphene import relay 5 | from graphene_django import DjangoObjectType 6 | from graphene_django.filter import DjangoFilterConnectionField 7 | from graphql_relay import from_global_id 8 | from serious_django_graphene import ( 9 | FailableMutation, 10 | get_user_from_info, 11 | MutationExecutionException, 12 | ) 13 | from serious_django_services import NotPassed 14 | 15 | from claims.schema import OrganisationEntityNode 16 | from organisation.models import OrganisationAddress 17 | from organisation.permissions import ( 18 | CanCreateOrganisationEntityPermission, 19 | CanUpdateOrganisationEntityPermission, 20 | ) 21 | from organisation.services import OrganisationEntityService, OrganisationAddressService 22 | 23 | 24 | class OrganisationAddressNode(DjangoObjectType): 25 | class Meta: 26 | model = OrganisationAddress 27 | filter_fields = ["id"] 28 | interfaces = (relay.Node,) 29 | 30 | 31 | class CreateOrganisationEntity(FailableMutation): 32 | organisation_entity = graphene.Field(OrganisationEntityNode) 33 | 34 | class Arguments: 35 | name = graphene.String(required=True) 36 | short_name = graphene.String(required=False) 37 | parent_id = graphene.ID(required=False) 38 | locations = graphene.List(graphene.ID) 39 | 40 | @permissions_checker([IsAuthenticated, CanCreateOrganisationEntityPermission]) 41 | def mutate( 42 | self, info, name, short_name=NotPassed, locations=[], parent_id=NotPassed 43 | ): 44 | user = get_user_from_info(info) 45 | if parent_id != NotPassed: 46 | parent_id = int(from_global_id(parent_id)[1]) 47 | 48 | if locations != NotPassed: 49 | locations = [int(from_global_id(l)[1]) for l in locations] 50 | try: 51 | result = OrganisationEntityService.create_organisation_entity( 52 | user, 53 | name=name, 54 | short_name=short_name, 55 | locations=locations, 56 | parent_id=parent_id, 57 | ) 58 | except OrganisationEntityService.exceptions as e: 59 | raise MutationExecutionException(str(e)) 60 | return CreateOrganisationEntity(success=True, organisation_entity=result) 61 | 62 | 63 | class UpdateOrganisationEntity(FailableMutation): 64 | organisation_entity = graphene.Field(OrganisationEntityNode) 65 | 66 | class Arguments: 67 | organisation_entity_id = graphene.ID(required=True) 68 | name = graphene.String(required=False) 69 | short_name = graphene.String(required=False) 70 | locations = graphene.List(graphene.ID) 71 | parent_id = graphene.ID(required=False) 72 | 73 | @permissions_checker([IsAuthenticated, CanUpdateOrganisationEntityPermission]) 74 | def mutate( 75 | self, 76 | info, 77 | organisation_entity_id, 78 | name=NotPassed, 79 | locations=NotPassed, 80 | short_name=NotPassed, 81 | parent_id=NotPassed, 82 | ): 83 | user = get_user_from_info(info) 84 | if parent_id != NotPassed: 85 | parent_id = int(from_global_id(parent_id)[1]) 86 | if locations != NotPassed: 87 | locations = [int(from_global_id(l)[1]) for l in locations] 88 | try: 89 | result = OrganisationEntityService.update_organisation_entity( 90 | user, 91 | int(from_global_id(organisation_entity_id)[1]), 92 | name=name, 93 | short_name=short_name, 94 | locations=locations, 95 | parent_id=parent_id, 96 | ) 97 | except OrganisationEntityService.exceptions as e: 98 | raise MutationExecutionException(str(e)) 99 | return UpdateOrganisationEntity(success=True, organisation_entity=result) 100 | 101 | 102 | class CreateOrganisationAddress(FailableMutation): 103 | organisation_address = graphene.Field(OrganisationAddressNode) 104 | 105 | class Arguments: 106 | name = graphene.String(required=True) 107 | street = graphene.String(required=False) 108 | city = graphene.String(required=True) 109 | postal_code = graphene.String(required=True) 110 | country = graphene.String(required=True) 111 | phone_prefix = graphene.String(required=True) 112 | 113 | @permissions_checker([IsAuthenticated, CanCreateOrganisationEntityPermission]) 114 | def mutate(self, info, name, street, city, postal_code, country, phone_prefix): 115 | user = get_user_from_info(info) 116 | 117 | try: 118 | result = OrganisationAddressService.create_address( 119 | user, 120 | name=name, 121 | street=street, 122 | city=city, 123 | postal_code=postal_code, 124 | country=country, 125 | phone_prefix=phone_prefix, 126 | ) 127 | except OrganisationAddressService.exceptions as e: 128 | raise MutationExecutionException(str(e)) 129 | return CreateOrganisationAddress(success=True, organisation_address=result) 130 | 131 | 132 | class UpdateOrganisationAddress(FailableMutation): 133 | organisation_address = graphene.Field(OrganisationAddressNode) 134 | 135 | class Arguments: 136 | organisation_address_id = graphene.ID(required=True) 137 | name = graphene.String(required=False) 138 | street = graphene.String(required=False) 139 | city = graphene.String(required=False) 140 | postal_code = graphene.String(required=False) 141 | country = graphene.String(required=False) 142 | phone_prefix = graphene.String(required=False) 143 | 144 | @permissions_checker([IsAuthenticated, CanUpdateOrganisationEntityPermission]) 145 | def mutate( 146 | self, 147 | info, 148 | organisation_address_id, 149 | name=NotPassed, 150 | street=NotPassed, 151 | city=NotPassed, 152 | postal_code=NotPassed, 153 | phone_prefix=NotPassed, 154 | country=NotPassed, 155 | ): 156 | user = get_user_from_info(info) 157 | try: 158 | result = OrganisationAddressService.update_address( 159 | user, 160 | int(from_global_id(organisation_address_id)[1]), 161 | name=name, 162 | street=street, 163 | city=city, 164 | postal_code=postal_code, 165 | phone_prefix=phone_prefix, 166 | country=country, 167 | ) 168 | except OrganisationAddressService.exceptions as e: 169 | raise MutationExecutionException(str(e)) 170 | return UpdateOrganisationEntity(success=True, organisation_address=result) 171 | 172 | 173 | class Mutation(graphene.ObjectType): 174 | create_organisation_entity = CreateOrganisationEntity.Field() 175 | update_organisation_entity = UpdateOrganisationEntity.Field() 176 | create_organisation_address = CreateOrganisationAddress.Field() 177 | update_organisation_address = UpdateOrganisationAddress.Field() 178 | 179 | 180 | class Query(graphene.ObjectType): 181 | organisation_address = relay.Node.Field(OrganisationAddressNode) 182 | all_organisation_addresses = DjangoFilterConnectionField(OrganisationAddressNode) 183 | 184 | 185 | ## Schema 186 | schema = graphene.Schema(query=Query, mutation=Mutation) 187 | -------------------------------------------------------------------------------- /claims/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from django_graphene_permissions import permissions_checker 3 | from django_graphene_permissions.permissions import IsAuthenticated 4 | from graphene import relay, Connection, Union 5 | from graphene_django import DjangoObjectType 6 | from graphene_django.filter import DjangoFilterConnectionField 7 | from graphql_relay import from_global_id 8 | from serious_django_graphene import ( 9 | FailableMutation, 10 | get_user_from_info, 11 | MutationExecutionException, 12 | ) 13 | 14 | from claims.models import ( 15 | ValueClaim, 16 | RelationshipClaim, 17 | Claim, 18 | Entity, 19 | ClaimType, 20 | ClaimSource, 21 | ) 22 | from claims.permissions import CanCreateValueClaimPermission 23 | from claims.services import ValueClaimService, ClaimService, RelationshipClaimService 24 | from organisation.models import OrganisationEntity 25 | from person.models import Person 26 | 27 | 28 | class ClaimSourceNode(DjangoObjectType): 29 | class Meta: 30 | model = ClaimSource 31 | filter_fields = ["id"] 32 | interfaces = (relay.Node,) 33 | 34 | 35 | class ClaimTypeType(DjangoObjectType): 36 | class Meta: 37 | model = ClaimType 38 | filter_fields = ["id"] 39 | connection_class = Connection 40 | interfaces = (relay.Node,) 41 | 42 | 43 | class ClaimTypeInterface(graphene.Interface): 44 | created_at = graphene.DateTime(required=True) 45 | source = graphene.List(ClaimSourceNode) 46 | claim_type = graphene.Field(ClaimTypeType) 47 | 48 | class Meta: 49 | interfaces = (relay.Node,) 50 | 51 | 52 | class ValueClaimType(DjangoObjectType): 53 | class Meta: 54 | model = ValueClaim 55 | filter_fields = ["id"] 56 | connection_class = Connection 57 | interfaces = (relay.Node, ClaimTypeInterface) 58 | 59 | 60 | class RelationshipClaimType(DjangoObjectType): 61 | target = graphene.Field(lambda: EntityUnion) 62 | entity = graphene.Field(lambda: EntityUnion) 63 | 64 | def resolve_target(root, info): 65 | print(root) 66 | return root.target 67 | 68 | class Meta: 69 | model = RelationshipClaim 70 | filter_fields = ["id"] 71 | connection_class = Connection 72 | interfaces = (relay.Node, ClaimTypeInterface) 73 | 74 | 75 | class ClaimUnion(Union): 76 | class Meta: 77 | types = (ValueClaimType, RelationshipClaimType) 78 | 79 | 80 | class ClaimConnection(graphene.Connection): 81 | class Meta: 82 | node = ClaimUnion 83 | 84 | 85 | class EntityTypeInterface(graphene.Interface): 86 | claims = graphene.ConnectionField(ClaimConnection) 87 | reverse_claims = graphene.ConnectionField(ClaimConnection) 88 | created_at = graphene.DateTime(required=True) 89 | 90 | def resolve_claims(root, info): 91 | return root.claims.all() 92 | 93 | def resolve_reverse_claims(root, info): 94 | return root.reverse_claims.all() 95 | 96 | class Meta: 97 | model = Entity 98 | interfaces = (relay.Node,) 99 | 100 | 101 | class PersonNode(DjangoObjectType): 102 | class Meta: 103 | model = Person 104 | filter_fields = ["id"] 105 | interfaces = ( 106 | relay.Node, 107 | EntityTypeInterface, 108 | ) 109 | 110 | 111 | class OrganisationEntityNode(DjangoObjectType): 112 | class Meta: 113 | model = OrganisationEntity 114 | filter_fields = ["id"] 115 | interfaces = ( 116 | relay.Node, 117 | EntityTypeInterface, 118 | ) 119 | 120 | 121 | class EntityUnion(Union): 122 | @classmethod 123 | def resolve_type(cls, instance, info): 124 | if isinstance(instance, Person): 125 | return PersonNode 126 | if isinstance(instance, OrganisationEntity): 127 | return OrganisationEntityNode 128 | 129 | class Meta: 130 | types = ( 131 | PersonNode, 132 | OrganisationEntityNode, 133 | ) 134 | 135 | 136 | class EntityConnection(graphene.Connection): 137 | class Meta: 138 | node = EntityUnion 139 | 140 | 141 | class CreateValueClaim(FailableMutation): 142 | value_claim = graphene.Field(ValueClaimType) 143 | 144 | class Arguments: 145 | value = graphene.JSONString(required=True) 146 | entity_id = graphene.ID(required=True) 147 | claim_type_id = graphene.ID(required=True) 148 | 149 | @permissions_checker([IsAuthenticated, CanCreateValueClaimPermission]) 150 | def mutate(self, info, value, entity_id, claim_type_id): 151 | user = get_user_from_info(info) 152 | try: 153 | result = ValueClaimService.create_value_claim( 154 | user, 155 | entity_id=int(from_global_id(entity_id)[1]), 156 | claim_type_id=int(from_global_id(claim_type_id)[1]), 157 | value=value, 158 | ) 159 | except ValueClaimService.exceptions as e: 160 | raise MutationExecutionException(str(e)) 161 | return CreateValueClaim(success=True, value_claim=result) 162 | 163 | 164 | class CreateRelationshipClaim(FailableMutation): 165 | relationship_claim = graphene.Field(RelationshipClaimType) 166 | 167 | class Arguments: 168 | value = graphene.JSONString(required=True) 169 | entity_id = graphene.ID(required=True) 170 | target_id = graphene.ID(required=True) 171 | claim_type_id = graphene.ID(required=True) 172 | 173 | @permissions_checker([IsAuthenticated, CanCreateValueClaimPermission]) 174 | def mutate(self, info, value, entity_id, target_id, claim_type_id): 175 | user = get_user_from_info(info) 176 | try: 177 | result = RelationshipClaimService.create_relationship_claim( 178 | user, 179 | entity_id=int(from_global_id(entity_id)[1]), 180 | target_id=int(from_global_id(target_id)[1]), 181 | claim_type_id=int(from_global_id(claim_type_id)[1]), 182 | value=value, 183 | ) 184 | except RelationshipClaimService.exceptions as e: 185 | raise MutationExecutionException(str(e)) 186 | return CreateRelationshipClaim(success=True, relationship_claim=result) 187 | 188 | 189 | class UpdateClaim(FailableMutation): 190 | claim = graphene.Field(ClaimUnion) 191 | 192 | class Arguments: 193 | value = graphene.JSONString(required=True) 194 | claim_id = graphene.ID(required=True) 195 | 196 | @permissions_checker([IsAuthenticated, CanCreateValueClaimPermission]) 197 | def mutate(self, info, claim_id, value): 198 | user = get_user_from_info(info) 199 | try: 200 | result = ClaimService.update_claim( 201 | user, claim_id=int(from_global_id(claim_id)[1]), value=value 202 | ) 203 | except ClaimService.exceptions as e: 204 | raise MutationExecutionException(str(e)) 205 | return UpdateClaim(success=True, claim=result) 206 | 207 | 208 | class Query(graphene.ObjectType): 209 | claims = graphene.ConnectionField(ClaimConnection) 210 | person = relay.Node.Field(PersonNode) 211 | organisation_entity = relay.Node.Field(OrganisationEntityNode) 212 | all_organisation_entities = DjangoFilterConnectionField(OrganisationEntityNode) 213 | claim_source = relay.Node.Field(ClaimSourceNode) 214 | all_people = DjangoFilterConnectionField(PersonNode) 215 | all_claim_types = DjangoFilterConnectionField(ClaimTypeType) 216 | 217 | def resolve_claims(self, info): 218 | return Claim.objects.all() 219 | 220 | 221 | class Mutation(graphene.ObjectType): 222 | create_value_claim = CreateValueClaim.Field() 223 | create_relationship_claim = CreateRelationshipClaim.Field() 224 | update_claim = UpdateClaim.Field() 225 | 226 | 227 | schema = graphene.Schema(query=Query, mutation=Mutation) 228 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python,django,intellij 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,django,intellij 4 | 5 | ### Django ### 6 | *.log 7 | *.pot 8 | *.pyc 9 | __pycache__/ 10 | local_settings.py 11 | db.sqlite3 12 | db.sqlite3-journal 13 | media 14 | dev_media/ 15 | 16 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 17 | # in your Git repository. Update and uncomment the following line accordingly. 18 | # /staticfiles/ 19 | 20 | ### Django.Python Stack ### 21 | # Byte-compiled / optimized / DLL files 22 | *.py[cod] 23 | *$py.class 24 | 25 | # C extensions 26 | *.so 27 | 28 | # Distribution / packaging 29 | .Python 30 | build/ 31 | develop-eggs/ 32 | dist/ 33 | downloads/ 34 | eggs/ 35 | .eggs/ 36 | lib/ 37 | lib64/ 38 | parts/ 39 | sdist/ 40 | var/ 41 | wheels/ 42 | share/python-wheels/ 43 | *.egg-info/ 44 | .installed.cfg 45 | *.egg 46 | MANIFEST 47 | 48 | # PyInstaller 49 | # Usually these files are written by a python script from a template 50 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 51 | *.manifest 52 | *.spec 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .nox/ 62 | .coverage 63 | .coverage.* 64 | .cache 65 | nosetests.xml 66 | coverage.xml 67 | *.cover 68 | *.py,cover 69 | .hypothesis/ 70 | .pytest_cache/ 71 | cover/ 72 | 73 | # Translations 74 | *.mo 75 | 76 | # Django stuff: 77 | 78 | # Flask stuff: 79 | instance/ 80 | .webassets-cache 81 | 82 | # Scrapy stuff: 83 | .scrapy 84 | 85 | # Sphinx documentation 86 | docs/_build/ 87 | 88 | # PyBuilder 89 | .pybuilder/ 90 | target/ 91 | 92 | # Jupyter Notebook 93 | .ipynb_checkpoints 94 | 95 | # IPython 96 | profile_default/ 97 | ipython_config.py 98 | 99 | # pyenv 100 | # For a library or package, you might want to ignore these files since the code is 101 | # intended to run in multiple environments; otherwise, check them in: 102 | # .python-version 103 | 104 | # pipenv 105 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 106 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 107 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 108 | # install all needed dependencies. 109 | #Pipfile.lock 110 | 111 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 112 | __pypackages__/ 113 | 114 | # Celery stuff 115 | celerybeat-schedule 116 | celerybeat.pid 117 | 118 | # SageMath parsed files 119 | *.sage.py 120 | 121 | # Environments 122 | .env 123 | .venv 124 | env/ 125 | venv/ 126 | ENV/ 127 | env.bak/ 128 | venv.bak/ 129 | 130 | # Spyder project settings 131 | .spyderproject 132 | .spyproject 133 | 134 | # Rope project settings 135 | .ropeproject 136 | 137 | # mkdocs documentation 138 | /site 139 | 140 | # mypy 141 | .mypy_cache/ 142 | .dmypy.json 143 | dmypy.json 144 | 145 | # Pyre type checker 146 | .pyre/ 147 | 148 | # pytype static type analyzer 149 | .pytype/ 150 | 151 | # Cython debug symbols 152 | cython_debug/ 153 | 154 | ### Intellij ### 155 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 156 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 157 | 158 | # User-specific stuff 159 | .idea/**/workspace.xml 160 | .idea/**/tasks.xml 161 | .idea/**/usage.statistics.xml 162 | .idea/**/dictionaries 163 | .idea/**/shelf 164 | 165 | # AWS User-specific 166 | .idea/**/aws.xml 167 | 168 | # Generated files 169 | .idea/**/contentModel.xml 170 | 171 | # Sensitive or high-churn files 172 | .idea/**/dataSources/ 173 | .idea/**/dataSources.ids 174 | .idea/**/dataSources.local.xml 175 | .idea/**/sqlDataSources.xml 176 | .idea/**/dynamic.xml 177 | .idea/**/uiDesigner.xml 178 | .idea/**/dbnavigator.xml 179 | 180 | # Gradle 181 | .idea/**/gradle.xml 182 | .idea/**/libraries 183 | 184 | # Gradle and Maven with auto-import 185 | # When using Gradle or Maven with auto-import, you should exclude module files, 186 | # since they will be recreated, and may cause churn. Uncomment if using 187 | # auto-import. 188 | # .idea/artifacts 189 | # .idea/compiler.xml 190 | # .idea/jarRepositories.xml 191 | # .idea/modules.xml 192 | # .idea/*.iml 193 | # .idea/modules 194 | # *.iml 195 | # *.ipr 196 | 197 | # CMake 198 | cmake-build-*/ 199 | 200 | # Mongo Explorer plugin 201 | .idea/**/mongoSettings.xml 202 | 203 | # File-based project format 204 | *.iws 205 | 206 | .idea/ 207 | 208 | # IntelliJ 209 | out/ 210 | 211 | # mpeltonen/sbt-idea plugin 212 | .idea_modules/ 213 | 214 | # dev config 215 | settings/configs/dev.py 216 | 217 | # JIRA plugin 218 | atlassian-ide-plugin.xml 219 | 220 | # Cursive Clojure plugin 221 | .idea/replstate.xml 222 | 223 | # Crashlytics plugin (for Android Studio and IntelliJ) 224 | com_crashlytics_export_strings.xml 225 | crashlytics.properties 226 | crashlytics-build.properties 227 | fabric.properties 228 | 229 | # Editor-based Rest Client 230 | .idea/httpRequests 231 | 232 | # Android studio 3.1+ serialized cache file 233 | .idea/caches/build_file_checksums.ser 234 | 235 | ### Intellij Patch ### 236 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 237 | 238 | # *.iml 239 | # modules.xml 240 | # .idea/misc.xml 241 | # *.ipr 242 | 243 | # Sonarlint plugin 244 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 245 | .idea/**/sonarlint/ 246 | 247 | # SonarQube Plugin 248 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 249 | .idea/**/sonarIssues.xml 250 | 251 | # Markdown Navigator plugin 252 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 253 | .idea/**/markdown-navigator.xml 254 | .idea/**/markdown-navigator-enh.xml 255 | .idea/**/markdown-navigator/ 256 | 257 | # Cache file creation bug 258 | # See https://youtrack.jetbrains.com/issue/JBR-2257 259 | .idea/$CACHE_FILE$ 260 | 261 | # CodeStream plugin 262 | # https://plugins.jetbrains.com/plugin/12206-codestream 263 | .idea/codestream.xml 264 | 265 | ### Python ### 266 | # Byte-compiled / optimized / DLL files 267 | 268 | # C extensions 269 | 270 | # Distribution / packaging 271 | 272 | # PyInstaller 273 | # Usually these files are written by a python script from a template 274 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 275 | 276 | # Installer logs 277 | 278 | # Unit test / coverage reports 279 | 280 | # Translations 281 | 282 | # Django stuff: 283 | 284 | # Flask stuff: 285 | 286 | # Scrapy stuff: 287 | 288 | # Sphinx documentation 289 | 290 | # PyBuilder 291 | 292 | # Jupyter Notebook 293 | 294 | # IPython 295 | 296 | # pyenv 297 | # For a library or package, you might want to ignore these files since the code is 298 | # intended to run in multiple environments; otherwise, check them in: 299 | # .python-version 300 | 301 | # pipenv 302 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 303 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 304 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 305 | # install all needed dependencies. 306 | 307 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 308 | 309 | # Celery stuff 310 | 311 | # SageMath parsed files 312 | 313 | # Environments 314 | 315 | # Spyder project settings 316 | 317 | # Rope project settings 318 | 319 | # mkdocs documentation 320 | 321 | # mypy 322 | 323 | # Pyre type checker 324 | 325 | # pytype static type analyzer 326 | 327 | # Cython debug symbols 328 | 329 | # End of https://www.toptal.com/developers/gitignore/api/python,django,intellij 330 | 331 | # Elastic Beanstalk Files 332 | .elasticbeanstalk/* 333 | !.elasticbeanstalk/*.cfg.yml 334 | !.elasticbeanstalk/*.global.yml 335 | -------------------------------------------------------------------------------- /organisation/services.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import reversion 4 | from django.contrib.auth.models import AbstractUser 5 | from serious_django_services import Service, NotPassed, CRUDMixin 6 | 7 | from organisation.forms import ( 8 | UpdateOrganisationEntityForm, 9 | CreateOrganisationEntityForm, 10 | UpdateOrganisationAddressForm, 11 | CreateOrganisationAddressForm, 12 | ) 13 | from organisation.models import OrganisationEntity, OrganisationAddress 14 | from organisation.permissions import ( 15 | CanCreateOrganisationEntityPermission, 16 | CanUpdateOrganisationEntityPermission, 17 | CanUpdateOrganisationAddressPermission, 18 | CanCreateOrganisationAddressPermission, 19 | ) 20 | 21 | 22 | class OrganisationEntityServiceException(Exception): 23 | pass 24 | 25 | 26 | class OrganisationAddressServiceException(Exception): 27 | pass 28 | 29 | 30 | class OrganisationEntityService(Service, CRUDMixin): 31 | update_form = UpdateOrganisationEntityForm 32 | create_form = CreateOrganisationEntityForm 33 | 34 | service_exceptions = (OrganisationEntityServiceException,) 35 | model = OrganisationEntity 36 | 37 | @classmethod 38 | def retrieve_organisation_entity(cls, id: int) -> OrganisationEntity: 39 | """ 40 | get an organisation_entity by id 41 | :param id: id of the organisation_entity 42 | :return: the organisation_entity object 43 | """ 44 | try: 45 | org_entity = cls.model.objects.get(pk=id) 46 | except cls.model.DoesNotExist: 47 | raise OrganisationEntityServiceException("OrganisationEntity not found.") 48 | 49 | return org_entity 50 | 51 | @classmethod 52 | def create_organisation_entity( 53 | cls, 54 | user: AbstractUser, 55 | name: str, 56 | short_name: str = NotPassed, 57 | parent_id: int = NotPassed, 58 | locations: List[int] = NotPassed, 59 | ) -> OrganisationEntity: 60 | """create a new OrganisationEntity 61 | :param user: the user calling the service 62 | :param name: - full name of the entity "Bundesminsiterium für Bildung und Forschung 63 | :param short_name: - short_name "BMBF" (Optional) 64 | :param parent_id: - id of the parent institution (Optional) 65 | :param locations: - list of location ids (Optional) 66 | :returns: the newly created organisation_entity instance 67 | """ 68 | 69 | if not user.has_perm(CanCreateOrganisationEntityPermission): 70 | raise PermissionError( 71 | "You are not allowed to create an OrganisationEntity." 72 | ) 73 | 74 | with reversion.create_revision(): 75 | person = cls._create( 76 | { 77 | "name": name, 78 | "short_name": short_name, 79 | "parent": parent_id, 80 | "locations": locations, 81 | } 82 | ) 83 | reversion.set_user(user) 84 | 85 | return person 86 | 87 | @classmethod 88 | def update_organisation_entity( 89 | cls, 90 | user: AbstractUser, 91 | organisation_entity_id: int, 92 | name: str = NotPassed, 93 | short_name: str = NotPassed, 94 | parent_id: int = NotPassed, 95 | locations: List[int] = NotPassed, 96 | ) -> OrganisationEntity: 97 | """create a new person 98 | :param organisation_entity_id: - ID of the exsisting entity that should be updated 99 | :param user: the user calling the service 100 | :param name: - full name of the entity "Bundesminsiterium für Bildung und Forschung 101 | :param short_name: - short_name "BMBF" (Optional) 102 | :param parent_id: - id of the parent institution (Optional) 103 | :param locations: - list of location ids (Optional) 104 | :returns: the updated organisation_entity instance 105 | """ 106 | 107 | organisation_entity = cls.retrieve_organisation_entity(organisation_entity_id) 108 | 109 | if not user.has_perm( 110 | CanUpdateOrganisationEntityPermission, organisation_entity 111 | ): 112 | raise PermissionError( 113 | "You are not allowed to update this OrganisationEntity." 114 | ) 115 | 116 | with reversion.create_revision(): 117 | organisation_entity = cls._update( 118 | organisation_entity_id, 119 | { 120 | "name": name, 121 | "short_name": short_name, 122 | "parent": parent_id, 123 | "locations": locations, 124 | }, 125 | ) 126 | reversion.set_user(user) 127 | reversion.set_comment(f"update via service by {user}") 128 | 129 | organisation_entity.refresh_from_db() 130 | return organisation_entity 131 | 132 | 133 | class OrganisationAddressService(Service, CRUDMixin): 134 | service_exceptions = (OrganisationAddressServiceException,) 135 | update_form = UpdateOrganisationAddressForm 136 | create_form = CreateOrganisationAddressForm 137 | model = OrganisationAddress 138 | 139 | @classmethod 140 | def retrieve_organisation_address(cls, id: int) -> OrganisationAddress: 141 | """ 142 | get an OrganisationAddress by id 143 | :param id: id of the OrganisationAddress 144 | :return: the OrganisationAddress object 145 | """ 146 | try: 147 | org_address = cls.model.objects.get(pk=id) 148 | except cls.model.DoesNotExist: 149 | raise OrganisationEntityServiceException("OrganisationAddress not found.") 150 | 151 | return org_address 152 | 153 | @classmethod 154 | def create_address( 155 | cls, user, name, street, city, postal_code, country, phone_prefix=NotPassed 156 | ): 157 | """ 158 | create a new address object 159 | :param user: user calling the service 160 | :param name: name of the address e.g. "Hauptsitz" 161 | :param street: Street and houseno 162 | :param city: city name 163 | :param postal_code: postal code 164 | :param country: country 2digit iso code e.g. "DE" 165 | """ 166 | 167 | if not user.has_perm(CanCreateOrganisationAddressPermission): 168 | raise PermissionError( 169 | "You are not allowed to create an OrganisationAddress." 170 | ) 171 | 172 | with reversion.create_revision(): 173 | organisation_address = cls._create( 174 | { 175 | "name": name, 176 | "street": street, 177 | "city": city, 178 | "postal_code": postal_code, 179 | "country": country, 180 | "phone_prefix": phone_prefix, 181 | } 182 | ) 183 | reversion.set_user(user) 184 | 185 | return organisation_address 186 | 187 | @classmethod 188 | def update_address( 189 | cls, 190 | user, 191 | address_id, 192 | name=NotPassed, 193 | street=NotPassed, 194 | city=NotPassed, 195 | postal_code=NotPassed, 196 | country=NotPassed, 197 | phone_prefix=NotPassed, 198 | ): 199 | """ 200 | create a new address object 201 | :param user: user calling the service 202 | :param address_id: address_id of the address that should be updated 203 | :param name: name of the address e.g. "Hauptsitz" 204 | :param street: Street and houseno 205 | :param city: city name 206 | :param postal_code: postal code 207 | :param phone_prefix: phone_prefix 208 | :param country: country 2digit iso code e.g. "DE" 209 | """ 210 | 211 | organisation_address = cls.retrieve_organisation_address(address_id) 212 | 213 | if not user.has_perm( 214 | CanUpdateOrganisationAddressPermission, organisation_address 215 | ): 216 | raise PermissionError( 217 | "You are not allowed to update this OrganisationAddress object." 218 | ) 219 | 220 | with reversion.create_revision(): 221 | organisation_address = cls._update( 222 | organisation_address.pk, 223 | { 224 | "name": name, 225 | "street": street, 226 | "city": city, 227 | "postal_code": postal_code, 228 | "country": country, 229 | "phone_prefix": phone_prefix, 230 | }, 231 | ) 232 | reversion.set_user(user) 233 | reversion.set_comment(f"update via service by {user}") 234 | 235 | organisation_address.refresh_from_db() 236 | return organisation_address 237 | -------------------------------------------------------------------------------- /claims/services.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import reversion 4 | from django.contrib.auth.models import AbstractUser 5 | from django.contrib.contenttypes.models import ContentType, ContentTypeManager 6 | from jsonschema.exceptions import ValidationError 7 | 8 | from serious_django_services import Service, CRUDMixin, NotPassed 9 | from jsonschema import validate 10 | 11 | from claims.forms import CreateValueClaimForm, UpdateValueClaimForm 12 | from claims.models import Entity, ClaimType, ValueClaim, Claim, RelationshipClaim 13 | from claims.permissions import CanCreateValueClaimPermission 14 | 15 | 16 | class EntityServiceException(Exception): 17 | pass 18 | 19 | 20 | class ClaimTypeServiceException(Exception): 21 | pass 22 | 23 | 24 | class ClaimServiceException(Exception): 25 | pass 26 | 27 | 28 | class EntityService(Service): 29 | service_exceptions = (EntityServiceException,) 30 | model = Entity 31 | 32 | @classmethod 33 | def resolve_entity(cls, entity_id: int): 34 | """ 35 | get an entity by id 36 | :param entity_id: id of the organisation_entity 37 | :return: the entity object 38 | """ 39 | try: 40 | entity = cls.model.objects.get(pk=entity_id) 41 | except cls.model.DoesNotExist: 42 | raise EntityServiceException("Entity not found.") 43 | 44 | return entity 45 | 46 | 47 | class ClaimTypeService(Service): 48 | service_exceptions = (ClaimTypeServiceException,) 49 | model = ClaimType 50 | 51 | @classmethod 52 | def resolve_claim_type(cls, claim_type_id: int): 53 | """ 54 | get an claim_type by id 55 | :param claim_type_id: id of the claim_type 56 | :return: the claim_type object 57 | """ 58 | try: 59 | claim_type = cls.model.objects.get(pk=claim_type_id) 60 | except cls.model.DoesNotExist: 61 | raise ClaimTypeServiceException("ClaimType not found.") 62 | 63 | return claim_type 64 | 65 | @classmethod 66 | def resolve_claim_type_by_codename(cls, code_name: str): 67 | """ 68 | get a claim type by its codename 69 | :param code_name: name of the claim you want to query 70 | :return: the claim object 71 | """ 72 | try: 73 | claim_type = cls.model.objects.get(code_name=code_name) 74 | except cls.model.DoesNotExist: 75 | raise ClaimServiceException("ClaimType not found.") 76 | 77 | return claim_type 78 | 79 | 80 | class ClaimService(Service, CRUDMixin): 81 | model = ValueClaim 82 | create_form = CreateValueClaimForm 83 | update_form = UpdateValueClaimForm 84 | service_exceptions = (ValidationError, PermissionError, ClaimServiceException) 85 | 86 | @classmethod 87 | def resolve_claim(cls, claim_id: int): 88 | """ 89 | get a claim by id 90 | :param claim_id: id of the claim_type 91 | :return: the claim object 92 | """ 93 | try: 94 | claim = cls.model.objects.get(pk=claim_id) 95 | except cls.model.DoesNotExist: 96 | raise ClaimServiceException("Claim not found.") 97 | 98 | return claim 99 | 100 | @classmethod 101 | def update_claim(cls, user: AbstractUser, claim_id: int, value: str): 102 | """update a new value claim 103 | :param user: user calling the service 104 | :param claim_id: id of the claim that should be updated 105 | :param value: json value of the claim 106 | """ 107 | claim = ClaimService.resolve_claim(claim_id) 108 | 109 | # is the value valid json? 110 | validate(instance=value, schema=claim.claim_type.value_schema) 111 | 112 | if not user.has_perm(CanCreateValueClaimPermission): 113 | raise PermissionError("You are not allowed to create a ValueClaim.") 114 | 115 | with reversion.create_revision(): 116 | claim = cls._update( 117 | claim_id, 118 | {"value": value}, 119 | ) 120 | reversion.set_user(user) 121 | return claim 122 | 123 | 124 | class ValueClaimService(Service, CRUDMixin): 125 | model = ValueClaim 126 | create_form = CreateValueClaimForm 127 | update_form = UpdateValueClaimForm 128 | service_exceptions = (ValidationError, PermissionError, ClaimServiceException) 129 | 130 | @classmethod 131 | def create_value_claim( 132 | cls, user: AbstractUser, entity_id: int, claim_type_id: int, value: dict 133 | ): 134 | """create a new value claim 135 | :param user: user calling the service 136 | :param entity_id: entity id this claim is added to 137 | :param claim_type_id: claim type 138 | :param value: json value of the claim as dict 139 | """ 140 | entity = EntityService.resolve_entity(entity_id) 141 | claim_type = ClaimTypeService.resolve_claim_type(claim_type_id) 142 | 143 | # is this claim type allowed for this entity type? 144 | if ( 145 | ContentType.objects.get_for_model(entity) 146 | not in claim_type.content_type.all() 147 | ): 148 | raise ClaimServiceException( 149 | f"Entity Type {ContentType.objects.get_for_model(entity)} is not " 150 | f"supported by claim '{claim_type.name}'" 151 | f"(supported: {', '.join([c.name for c in claim_type.content_type.all()])})" 152 | ) 153 | # is the value valid json? 154 | validate(instance=value, schema=claim_type.value_schema) 155 | 156 | if not user.has_perm(CanCreateValueClaimPermission): 157 | raise PermissionError("You are not allowed to create a ValueClaim.") 158 | 159 | with reversion.create_revision(): 160 | value_claim = cls.model.objects.create( 161 | value=value, entity=entity, claim_type=claim_type 162 | ) 163 | reversion.set_user(user) 164 | return value_claim 165 | 166 | 167 | class RelationshipClaimService(Service, CRUDMixin): 168 | model = RelationshipClaim 169 | create_form = CreateValueClaimForm 170 | update_form = UpdateValueClaimForm 171 | service_exceptions = (ValidationError, PermissionError, ClaimServiceException) 172 | 173 | @classmethod 174 | def create_relationship_claim( 175 | cls, 176 | user: AbstractUser, 177 | entity_id: int, 178 | claim_type_id: int, 179 | target_id: int, 180 | value: str = NotPassed, 181 | ) -> object: 182 | """create a new relationship claim 183 | :param user: user calling the service 184 | :param entity_id: entity id this claim is added to 185 | :param claim_type_id: claim type 186 | :param value: json value of the claim 187 | """ 188 | entity = EntityService.resolve_entity(entity_id) 189 | target = EntityService.resolve_entity(target_id) 190 | claim_type = ClaimTypeService.resolve_claim_type(claim_type_id) 191 | 192 | # is this claim type allowed for this entity type? 193 | if ( 194 | ContentType.objects.get_for_model(entity) 195 | not in claim_type.content_type.all() 196 | ): 197 | raise ClaimServiceException( 198 | f"Entity Type {ContentType.objects.get_for_model(entity)} is not " 199 | f"supported by claim '{claim_type.name}' " 200 | f"(supported: {', '.join([c.name for c in claim_type.content_type.all()])})" 201 | ) 202 | 203 | # is this claim type allowed for this target type? 204 | if ( 205 | ContentType.objects.get_for_model(target) 206 | not in claim_type.content_type.all() 207 | ): 208 | raise ClaimServiceException( 209 | f"Entity Type {ContentType.objects.get_for_model(target)} is not " 210 | f"supported by claim '{claim_type.name}' " 211 | f"(supported: {', '.join([c.name for c in claim_type.content_type.all()])})" 212 | ) 213 | if claim_type.value_schema: 214 | # is the value valid json? 215 | validate(instance=value, schema=claim_type.value_schema) 216 | 217 | if not value: 218 | value = NotPassed 219 | print(value) 220 | if not claim_type.value_schema and value is not NotPassed: 221 | raise ClaimServiceException( 222 | "This claim dosen't support additional attributes" 223 | ) 224 | 225 | if not user.has_perm(CanCreateValueClaimPermission): 226 | raise PermissionError("You are not allowed to create a ValueClaim.") 227 | 228 | with reversion.create_revision(): 229 | if value is not NotPassed: 230 | relationship_claim = cls.model.objects.create( 231 | value=value, entity=entity, target=target, claim_type=claim_type 232 | ) 233 | else: 234 | relationship_claim = cls.model.objects.create( 235 | entity=entity, target=target, claim_type=claim_type 236 | ) 237 | reversion.set_user(user) 238 | return relationship_claim 239 | -------------------------------------------------------------------------------- /orgcharts/services.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import reversion 4 | from django.contrib.auth.models import AbstractUser 5 | from serious_django_services import Service, NotPassed, CRUDMixin 6 | from graphql_relay import from_global_id 7 | 8 | from claims.services import ( 9 | RelationshipClaimService, 10 | ClaimService, 11 | ClaimTypeService, 12 | ValueClaimService, 13 | ) 14 | from organisation.forms import ( 15 | UpdateOrganisationEntityForm, 16 | CreateOrganisationEntityForm, 17 | ) 18 | from organisation.models import OrganisationEntity 19 | from organisation.permissions import ( 20 | CanCreateOrganisationEntityPermission, 21 | CanUpdateOrganisationEntityPermission, 22 | ) 23 | from organisation.services import OrganisationEntityService 24 | from orgcharts.forms import ( 25 | CreateOrgChartURLForm, 26 | UpdateOrgChartURLForm, 27 | UpdateOrgChartErrorForm, 28 | CreateOrgChartErrorForm, 29 | CreateOrgChartForm, 30 | UpdateOrgChartForm, 31 | ) 32 | from orgcharts.models import OrgChartURL, OrgChart, OrgChartStatusChoices, OrgChartError 33 | from orgcharts.permissions import ( 34 | CanCreateOrgChartURLPermission, 35 | CanImportOrgChartPermission, 36 | CanCreateOrgChartErrorPermission, 37 | CanCreateOrgChartPermission, 38 | CanUpdateOrgChartPermission, 39 | ) 40 | from person.services import PersonService 41 | from django.conf import settings 42 | 43 | from typing import BinaryIO 44 | 45 | 46 | class OrgChartURLServiceException(Exception): 47 | pass 48 | 49 | 50 | class OrgChartImportServiceException(Exception): 51 | pass 52 | 53 | 54 | class OrgChartServiceException(Exception): 55 | pass 56 | 57 | 58 | class OrgChartURLService(Service, CRUDMixin): 59 | update_form = UpdateOrgChartURLForm 60 | create_form = CreateOrgChartURLForm 61 | 62 | service_exceptions = (OrgChartURLServiceException,) 63 | model = OrgChartURL 64 | 65 | @classmethod 66 | def retrieve_orgchart_url(cls, id: int) -> OrgChartURL: 67 | """ 68 | get an OrgChartUrl by id 69 | :param id: id of the OrgChartUrl 70 | :return: the OrgChartUrl object 71 | """ 72 | try: 73 | orgchart_url = cls.model.objects.get(pk=id) 74 | except cls.model.DoesNotExist: 75 | raise OrgChartURLServiceException("OrgChartURL not found.") 76 | 77 | return orgchart_url 78 | 79 | @classmethod 80 | def create_orgchart_url( 81 | cls, user: AbstractUser, url: str, entity_id: int 82 | ) -> OrgChartURL: 83 | """create a new OrganisationEntity 84 | :param user: the user calling the service 85 | :param url: - the url the orgchart can be found under 86 | :param entity_id: - sid of the organisation entity 87 | :returns: the newly created OrgChartUrl instance 88 | """ 89 | 90 | if not user.has_perm(CanCreateOrgChartURLPermission): 91 | raise PermissionError("You are not allowed to create an OrgChartUrl.") 92 | 93 | with reversion.create_revision(): 94 | org_chart_url = cls._create({"url": url, "organisation_entity": entity_id}) 95 | reversion.set_user(user) 96 | 97 | return org_chart_url 98 | 99 | 100 | class OrgChartService(Service, CRUDMixin): 101 | service_exceptions = (OrgChartServiceException,) 102 | model = OrgChart 103 | 104 | update_form = UpdateOrgChartForm 105 | create_form = CreateOrgChartForm 106 | 107 | @classmethod 108 | def update_orgchart( 109 | cls, 110 | user: AbstractUser, 111 | org_chart_id: str, 112 | raw_source: str, 113 | status: str, 114 | ) -> OrgChart: 115 | """create a new OrganisationEntity 116 | :param user: the user calling the service 117 | :param org_chart_id: the id of the orgchart document 118 | :param raw_source: the raw json object 119 | :param status: the hash of the document submitted 120 | :returns: the updated OrgChart instance 121 | """ 122 | 123 | if not user.has_perm(CanUpdateOrgChartPermission): 124 | raise PermissionError("You are not allowed to update an OrgChart.") 125 | 126 | with reversion.create_revision(): 127 | org_chart = cls._update( 128 | org_chart_id, 129 | {"raw_source": raw_source, "status": status}, 130 | ) 131 | reversion.set_user(user) 132 | 133 | return org_chart 134 | 135 | @classmethod 136 | def retrieve_orgchart(cls, id: int) -> OrgChart: 137 | """ 138 | get an OrgChart by id 139 | :param id: id of the OrgChartUrl 140 | :return: the OrgChart object 141 | """ 142 | try: 143 | orgchart = cls.model.objects.get(pk=id) 144 | except cls.model.DoesNotExist: 145 | raise OrgChartServiceException("OrgChart not found.") 146 | 147 | return orgchart 148 | 149 | @classmethod 150 | def create_orgchart( 151 | cls, 152 | user: AbstractUser, 153 | org_chart_url_id: str, 154 | document_hash: hash, 155 | document: BinaryIO, 156 | ) -> OrgChart: 157 | """create a new OrganisationEntity 158 | :param user: the user calling the service 159 | :param org_chart_url_id: - the url this orgchart document is related to 160 | :param document_hash: - the hash of the document submiited 161 | :param document: - the document itself as file object 162 | :returns: the newly created OrgChart instance 163 | """ 164 | 165 | if not user.has_perm(CanCreateOrgChartPermission): 166 | raise PermissionError("You are not allowed to create an OrgChart.") 167 | 168 | with reversion.create_revision(): 169 | org_chart = cls._create( 170 | {"org_chart_url": org_chart_url_id, "document_hash": document_hash}, 171 | {"document": document}, 172 | ) 173 | reversion.set_user(user) 174 | 175 | return org_chart 176 | 177 | 178 | class OrgChartImportService(Service): 179 | service_exceptions = (OrgChartImportServiceException,) 180 | 181 | SOURCE_URI = "ORGCHARTIMPORT_FROM_PDF:" 182 | 183 | @classmethod 184 | def import_entity( 185 | cls, user, orgchart_entity, organisation_id, orgchart_id, entity_dict 186 | ): 187 | entity = orgchart_entity["organisation"] 188 | import_src_id = cls.SOURCE_URI + str(orgchart_id) + ":" + entity["id"] 189 | parent = None 190 | if entity["parentId"]["val"] is None: 191 | parent = organisation_id 192 | elif entity["parentId"]["val"] not in entity_dict: 193 | raise OrgChartImportServiceException( 194 | 'Did not find any parent with ID {entity["parentId"]["val"]}' 195 | ) 196 | elif ( 197 | "imported" not in entity_dict[entity["parentId"]["val"]] 198 | or entity_dict[entity["parentId"]["val"]]["imported"] is False 199 | ): 200 | entity_dict = cls.import_entity( 201 | user, 202 | entity_dict[entity["parentId"]["val"]], 203 | organisation_id, 204 | orgchart_id, 205 | entity_dict, 206 | ) 207 | parent = entity_dict[entity["parentId"]["val"]]["internal_id"] 208 | print("import child") 209 | else: 210 | parent = entity_dict[entity["parentId"]["val"]]["internal_id"] 211 | 212 | if "shortName" not in entity: 213 | entity["shortName"] = "" 214 | if "name" not in entity: 215 | entity["name"] = "" 216 | curr_entity = OrganisationEntityService.create_organisation_entity( 217 | user, short_name=entity["shortName"], name=entity["name"], parent_id=parent 218 | ) 219 | 220 | for person in entity["people"]: 221 | # not sure if this is a permanent solution 222 | position = None 223 | if "position" in person and person["position"]: 224 | # TODO: figure our what from_global_id could throw here 225 | try: 226 | position = int(from_global_id(person["position"])[1]) 227 | except Exception: 228 | pass 229 | curr_person = PersonService.create_person(user, person["name"], position) 230 | claim = RelationshipClaimService.create_relationship_claim( 231 | user, 232 | curr_person.pk, 233 | ClaimTypeService.resolve_claim_type_by_codename( 234 | settings.CLAIMS["LEADS"] 235 | ).pk, 236 | curr_entity.pk, 237 | ) 238 | for dial_code in entity["dialCodes"]: 239 | ValueClaimService.create_value_claim( 240 | user, 241 | curr_entity.pk, 242 | ClaimTypeService.resolve_claim_type_by_codename( 243 | settings.CLAIMS["DIAL_CODE"] 244 | ).pk, 245 | {"dialCode": dial_code}, 246 | ) 247 | entity_dict[entity["id"]]["imported"] = True 248 | entity_dict[entity["id"]]["internal_id"] = curr_entity.pk 249 | print(entity_dict) 250 | return entity_dict 251 | 252 | @classmethod 253 | def import_organisation_entities(cls, user, entities, organisation_id, orgchart_id): 254 | entity_dict = {} 255 | for entity in entities: 256 | entity_dict[entity["organisation"]["id"]] = entity 257 | 258 | print(entity_dict) 259 | 260 | for entity in entities: 261 | if ( 262 | "imported" not in entity_dict[entity["organisation"]["id"]] 263 | or entity_dict[entity["organisation"]["id"]]["imported"] is not True 264 | ): 265 | cls.import_entity( 266 | user, 267 | entity_dict[entity["organisation"]["id"]], 268 | organisation_id, 269 | orgchart_id, 270 | entity_dict, 271 | ) 272 | 273 | return entity_dict 274 | 275 | @classmethod 276 | def import_parsed_orgchart(cls, user: AbstractUser, orgchart_id: int, orgchart): 277 | """initial parsing import of an orgchart pdf 278 | :param orgchart_id: the id the orgchart_id that should be imported 279 | :param orgchart: the orgchart object as Dict 280 | 281 | """ 282 | print(orgchart) 283 | if not user.has_perm(CanImportOrgChartPermission): 284 | raise PermissionError("You are not allowed to do initial orgchart imports.") 285 | 286 | orgchart_document = OrgChartService.retrieve_orgchart(orgchart_id) 287 | if orgchart_document.status not in [ 288 | OrgChartStatusChoices.NEW, 289 | OrgChartStatusChoices.PARSED, 290 | ]: 291 | raise OrgChartImportServiceException( 292 | f"Orgchart needs to be in status '{OrgChartStatusChoices.NEW}' to be " 293 | f"imported" 294 | ) 295 | 296 | if ( 297 | orgchart_document 298 | != orgchart_document.org_chart_url.orgchart_documents.order_by( 299 | "-id" 300 | ).first() 301 | ): 302 | raise OrgChartImportServiceException( 303 | f"This is not the latest Orgchart available. Please try to import " 304 | f"the latest version" 305 | ) 306 | 307 | if ( 308 | orgchart_document.org_chart_url.orgchart_documents.filter( 309 | status__in=[OrgChartStatusChoices.IMPORTED] 310 | ).first() 311 | is not None 312 | ): 313 | raise OrgChartImportServiceException( 314 | f"For the Organisation '{orgchart_document.org_chart_url.organisation_entity.name}' the initial " 315 | f"import is already done." 316 | ) 317 | 318 | if len(orgchart) == 0: 319 | raise OrgChartImportServiceException( 320 | f"Nothing to import! Json body seems to be empty" 321 | ) 322 | 323 | # update the raw source field for the document 324 | orgchart_document.raw_source = orgchart 325 | orgchart_document.save() 326 | 327 | organisation = orgchart_document.org_chart_url.organisation_entity 328 | 329 | cls.import_organisation_entities(user, orgchart, organisation, orgchart_id) 330 | 331 | orgchart_document.status = OrgChartStatusChoices.IMPORTED 332 | orgchart_document.save() 333 | 334 | return orgchart_document 335 | 336 | 337 | class OrgChartErrorService(Service, CRUDMixin): 338 | service_exceptions = (OrgChartServiceException,) 339 | model = OrgChartError 340 | 341 | update_form = UpdateOrgChartErrorForm 342 | create_form = CreateOrgChartErrorForm 343 | 344 | @classmethod 345 | def create_orgchart_error( 346 | cls, user: AbstractUser, org_chart_url_id: int, message: str 347 | ) -> OrgChartError: 348 | """create a new OrganisationEntity 349 | :param user: the user calling the service 350 | :param org_chart_url_id: - the url object the orgchart can be found under 351 | :param message: - the error message 352 | :returns: the newly created OrgChartError instance 353 | """ 354 | 355 | if not user.has_perm(CanCreateOrgChartErrorPermission): 356 | raise PermissionError("You are not allowed to create an OrgChartUrl.") 357 | 358 | with reversion.create_revision(): 359 | org_chart_error = cls._create( 360 | {"org_chart_url": org_chart_url_id, "message": message} 361 | ) 362 | reversion.set_user(user) 363 | 364 | return org_chart_error 365 | --------------------------------------------------------------------------------