cardo-python-utils 0.5.dev1__tar.gz → 0.5.dev4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {cardo_python_utils-0.5.dev1/cardo_python_utils.egg-info → cardo_python_utils-0.5.dev4}/PKG-INFO +4 -1
  2. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4/cardo_python_utils.egg-info}/PKG-INFO +4 -1
  3. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/cardo_python_utils.egg-info/SOURCES.txt +4 -1
  4. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/cardo_python_utils.egg-info/requires.txt +4 -0
  5. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/pyproject.toml +5 -1
  6. cardo_python_utils-0.5.dev4/python_utils/django/keycloak/admin.py +86 -0
  7. cardo_python_utils-0.5.dev4/python_utils/django/keycloak/models.py +19 -0
  8. cardo_python_utils-0.5.dev4/python_utils/django/keycloak/service.py +100 -0
  9. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/LICENSE +0 -0
  10. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/MANIFEST.in +0 -0
  11. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/README.rst +0 -0
  12. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/cardo_python_utils.egg-info/dependency_links.txt +0 -0
  13. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/cardo_python_utils.egg-info/top_level.txt +0 -0
  14. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/__init__.py +0 -0
  15. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/choices.py +0 -0
  16. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/data_structures.py +0 -0
  17. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/db.py +0 -0
  18. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/django/auth/__init__.py +0 -0
  19. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/django/auth/admin.py +0 -0
  20. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/django/auth/drf.py +0 -0
  21. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/django/auth/ninja.py +0 -0
  22. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/django/utils.py +0 -0
  23. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/esma_choices.py +0 -0
  24. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/exceptions.py +0 -0
  25. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/imports.py +0 -0
  26. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/math.py +0 -0
  27. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/text.py +0 -0
  28. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/time.py +0 -0
  29. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/python_utils/types_hinting.py +0 -0
  30. {cardo_python_utils-0.5.dev1 → cardo_python_utils-0.5.dev4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cardo-python-utils
3
- Version: 0.5.dev1
3
+ Version: 0.5.dev4
4
4
  Summary: Python library enhanced with a wide range of functions for different scenarios.
5
5
  Author-email: CardoAI <hello@cardoai.com>
6
6
  License: MIT
@@ -37,6 +37,9 @@ Requires-Dist: PyJWT; extra == "drf"
37
37
  Provides-Extra: django-admin-auth
38
38
  Requires-Dist: Django; extra == "django-admin-auth"
39
39
  Requires-Dist: mozilla-django-oidc>=4.0.1; extra == "django-admin-auth"
40
+ Provides-Extra: django-keycloak-groups
41
+ Requires-Dist: Django; extra == "django-keycloak-groups"
42
+ Requires-Dist: python-keycloak>=5.8.1; extra == "django-keycloak-groups"
40
43
  Provides-Extra: all
41
44
  Requires-Dist: Django; extra == "all"
42
45
  Provides-Extra: dev
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cardo-python-utils
3
- Version: 0.5.dev1
3
+ Version: 0.5.dev4
4
4
  Summary: Python library enhanced with a wide range of functions for different scenarios.
5
5
  Author-email: CardoAI <hello@cardoai.com>
6
6
  License: MIT
@@ -37,6 +37,9 @@ Requires-Dist: PyJWT; extra == "drf"
37
37
  Provides-Extra: django-admin-auth
38
38
  Requires-Dist: Django; extra == "django-admin-auth"
39
39
  Requires-Dist: mozilla-django-oidc>=4.0.1; extra == "django-admin-auth"
40
+ Provides-Extra: django-keycloak-groups
41
+ Requires-Dist: Django; extra == "django-keycloak-groups"
42
+ Requires-Dist: python-keycloak>=5.8.1; extra == "django-keycloak-groups"
40
43
  Provides-Extra: all
41
44
  Requires-Dist: Django; extra == "all"
42
45
  Provides-Extra: dev
@@ -22,4 +22,7 @@ python_utils/django/utils.py
22
22
  python_utils/django/auth/__init__.py
23
23
  python_utils/django/auth/admin.py
24
24
  python_utils/django/auth/drf.py
25
- python_utils/django/auth/ninja.py
25
+ python_utils/django/auth/ninja.py
26
+ python_utils/django/keycloak/admin.py
27
+ python_utils/django/keycloak/models.py
28
+ python_utils/django/keycloak/service.py
@@ -15,6 +15,10 @@ Django
15
15
  Django
16
16
  mozilla-django-oidc>=4.0.1
17
17
 
18
+ [django-keycloak-groups]
19
+ Django
20
+ python-keycloak>=5.8.1
21
+
18
22
  [django-ninja]
19
23
  Django
20
24
  django-ninja
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cardo-python-utils"
7
- version = "0.5.dev1"
7
+ version = "0.5.dev4"
8
8
  description = "Python library enhanced with a wide range of functions for different scenarios."
9
9
  readme = "README.rst"
10
10
  requires-python = ">=3.8"
@@ -48,6 +48,10 @@ django-admin-auth = [
48
48
  "Django",
49
49
  "mozilla-django-oidc>=4.0.1",
50
50
  ]
51
+ django-keycloak-groups = [
52
+ "Django",
53
+ "python-keycloak>=5.8.1",
54
+ ]
51
55
  all = [
52
56
  "Django",
53
57
  ]
@@ -0,0 +1,86 @@
1
+ from django.contrib import admin, messages
2
+ from django.core.cache import cache
3
+ from django.urls import path
4
+ from django.shortcuts import redirect
5
+
6
+ from .service import KeycloakService
7
+
8
+
9
+ class UserGroupAdmin(admin.ModelAdmin):
10
+ list_display = ("path",)
11
+ search_fields = ("id", "path")
12
+ readonly_fields = ("id", "path")
13
+
14
+ # To show ManyToMany fields with a horizontal filter widget
15
+ # filter_horizontal = ("allowed_entities",)
16
+
17
+ change_list_template = "user_groups_changelist.html"
18
+
19
+ # To show fields in this order in the detail view
20
+ # fieldsets = (
21
+ # (
22
+ # None,
23
+ # {
24
+ # "fields": (
25
+ # "id",
26
+ # "path",
27
+ # "allow_all_entities",
28
+ # "allowed_entities",
29
+ # )
30
+ # },
31
+ # ),
32
+ # )
33
+
34
+ def has_add_permission(self, request):
35
+ # Manual addition of user groups is not allowed
36
+ return False
37
+
38
+ def has_delete_permission(self, request, obj=None):
39
+ # Manual deletion of user groups is not allowed
40
+ return False
41
+
42
+ # To show annotated fields in the list view
43
+ # def get_queryset(self, request):
44
+ # return (
45
+ # super()
46
+ # .get_queryset(request)
47
+ # .annotate(allowed_job_configs_count=Count("allowed_job_configs"))
48
+ # )
49
+
50
+ def get_urls(self):
51
+ return [
52
+ path("sync-with-keycloak/", self.sync_groups_with_keycloak),
53
+ *super().get_urls(),
54
+ ]
55
+
56
+ @staticmethod
57
+ def sync_groups_with_keycloak(request): # noqa
58
+ """Syncs user groups with Keycloak"""
59
+
60
+ try:
61
+ KeycloakService().sync_user_groups(raise_exceptions=True)
62
+ messages.success(request, "User groups synced successfully.")
63
+ except Exception as e:
64
+ messages.error(request, f"Error syncing user groups: {e}")
65
+
66
+ return redirect("..")
67
+
68
+ # To allow sorting by annotated fields
69
+ # @admin.display(
70
+ # description="Allowed Job Configs", ordering="allowed_job_configs_count"
71
+ # )
72
+ # def allowed_job_configs_count(self, obj):
73
+ # return obj.allowed_job_configs_count
74
+
75
+ def changelist_view(self, request, extra_context=None):
76
+ """
77
+ When the list view is accessed, sync the user groups from Keycloak.
78
+ Cache the sync for 10 minutes to avoid excessive requests.
79
+ """
80
+ cache_key = "keycloak_group_sync_lock"
81
+
82
+ if cache.get(cache_key) is None:
83
+ KeycloakService().sync_user_groups()
84
+ cache.set(cache_key, "true", 60 * 10)
85
+
86
+ return super().changelist_view(request, extra_context)
@@ -0,0 +1,19 @@
1
+ from django.db import models
2
+
3
+
4
+ class UserGroupBase(models.Model):
5
+ """
6
+ Abstract base model for Keycloak user groups.
7
+ """
8
+ id = models.UUIDField(
9
+ primary_key=True,
10
+ help_text="The ID of the group, as coming from Keycloak.",
11
+ )
12
+ path = models.CharField(
13
+ max_length=255,
14
+ help_text="The full path of the group, as coming from Keycloak.",
15
+ db_index=True,
16
+ )
17
+
18
+ class Meta:
19
+ abstract = True
@@ -0,0 +1,100 @@
1
+ from django.apps import apps
2
+ from django.conf import settings
3
+ from keycloak import KeycloakAdmin
4
+ from keycloak import KeycloakOpenIDConnection
5
+ from keycloak.exceptions import KeycloakGetError
6
+
7
+
8
+ class KeycloakService:
9
+ def __init__(self):
10
+ self._user_group_model = self._get_user_group_model()
11
+ self._keycloak_admin = self._get_keycloak_admin()
12
+
13
+ def sync_user_groups(self, raise_exceptions: bool = False):
14
+ print("Syncing user groups from Keycloak...")
15
+
16
+ try:
17
+ groups = self._keycloak_admin.get_groups(full_hierarchy=True)
18
+ except KeycloakGetError as e:
19
+ print(f"Failed to fetch groups from Keycloak: {str(e)}")
20
+ if raise_exceptions:
21
+ raise e
22
+
23
+ return
24
+
25
+ # Process existing and new groups
26
+ existing_groups = self._user_group_model.objects.all()
27
+ existing_groups_by_id = {str(group.id): group for group in existing_groups}
28
+
29
+ reported_group_ids = set()
30
+ for group in groups:
31
+ self._process_group_recursively(
32
+ group, existing_groups_by_id, reported_group_ids
33
+ )
34
+
35
+ # Identify deleted groups
36
+ deleted_groups = self._user_group_model.objects.exclude(
37
+ id__in=reported_group_ids
38
+ )
39
+ if deleted_groups.exists():
40
+ print(
41
+ f"Deleting groups no longer present in Keycloak: {list(deleted_groups.values_list('path', flat=True))}"
42
+ )
43
+ deleted_groups.delete()
44
+
45
+ def _get_keycloak_admin(self):
46
+ keycloak_connection = KeycloakOpenIDConnection(
47
+ server_url=settings.KEYCLOAK_SERVER_URL,
48
+ realm_name=settings.KEYCLOAK_REALM,
49
+ user_realm_name=settings.KEYCLOAK_REALM,
50
+ client_id=settings.KEYCLOAK_ADMIN_CLIENT_ID,
51
+ client_secret_key=settings.KEYCLOAK_ADMIN_CLIENT_SECRET,
52
+ verify=True,
53
+ )
54
+ return KeycloakAdmin(connection=keycloak_connection)
55
+
56
+ def _get_user_group_model(self):
57
+ """
58
+ Dynamically get the UserGroup model.
59
+
60
+ Attempts to use the model specified in settings.KEYCLOAK_USER_GROUP_MODEL.
61
+
62
+ Returns:
63
+ The UserGroup model class.
64
+
65
+ Raises:
66
+ LookupError: If the model cannot be found.
67
+ """
68
+ try:
69
+ model_string = getattr(settings, "KEYCLOAK_USER_GROUP_MODEL")
70
+ except AttributeError:
71
+ raise LookupError(
72
+ "Please set KEYCLOAK_USER_GROUP_MODEL in your Django settings "
73
+ "(e.g., 'myapp.UserGroup')."
74
+ )
75
+
76
+ return apps.get_model(model_string)
77
+
78
+ def _process_group_recursively(
79
+ self, group, existing_groups_by_id, reported_group_ids
80
+ ):
81
+ group_id = str(group["id"])
82
+ reported_group_ids.add(group_id)
83
+
84
+ if group_id in existing_groups_by_id:
85
+ existing_group = existing_groups_by_id[group_id]
86
+ if existing_group.path != group["path"]:
87
+ print(
88
+ f"Updating group path from {existing_group.path} to {group['path']}..."
89
+ )
90
+ existing_group.path = group["path"]
91
+ existing_group.save()
92
+ else:
93
+ print(f"Creating new group with path {group['path']}...")
94
+ self._user_group_model.objects.create(id=group_id, path=group["path"])
95
+
96
+ if subgroups := group.get("subGroups"):
97
+ for subgroup in subgroups:
98
+ self._process_group_recursively(
99
+ subgroup, existing_groups_by_id, reported_group_ids
100
+ )