From d8a113d42308695e95b1f7b1af8cc7139312391c Mon Sep 17 00:00:00 2001 From: ankitamk14 Date: Mon, 9 Oct 2023 14:35:35 +0530 Subject: [PATCH] Backend For Group Permissions --- accounts/admin.py | 7 +- accounts/helper.py | 58 ++++++++ ...ext_permission_grouppermission_and_more.py | 140 ++++++++++++++++++ accounts/models.py | 136 ++++++++++++++++- accounts/urls.py | 9 +- accounts/views.py | 72 ++++++++- config.py | 7 + requests/accounts.rest | 34 +++++ 8 files changed, 458 insertions(+), 5 deletions(-) create mode 100644 accounts/migrations/0005_context_permission_grouppermission_and_more.py create mode 100644 requests/accounts.rest diff --git a/accounts/admin.py b/accounts/admin.py index 9b2d48a..030d3cf 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -2,7 +2,8 @@ from .models import User, Organisation, School, Payment, TrainingTeam, \ CentralCoordinator, SchoolCoordinator, Teacher, Parent, Profile, \ - Location, Condition, MessageType, Message + Location, Condition, MessageType, Message, Context, ContextAllowAssign, \ + Permission, GroupPermission class UserAdmin(admin.ModelAdmin): @@ -92,3 +93,7 @@ class ParentAdmin(admin.ModelAdmin): admin.site.register(Message) admin.site.register(MessageType) admin.site.register(Condition) +admin.site.register(Context) +admin.site.register(ContextAllowAssign) +admin.site.register(Permission) +admin.site.register(GroupPermission) diff --git a/accounts/helper.py b/accounts/helper.py index 0d2ba11..2e73296 100644 --- a/accounts/helper.py +++ b/accounts/helper.py @@ -1,5 +1,6 @@ from django.contrib.auth.hashers import make_password from common.serializers import LocationSerializer +from .models import GroupPermission def set_encrypted_password(password): @@ -98,3 +99,60 @@ def save_location_data(location_data, obj=None): ValidationError: If the location data is invalid. """ return save_data(LocationSerializer, location_data, obj) + + +def manage_group_permissions(group, permissions): + """ + Manages group permissions for a given group by processing a dictionary of permissions. + + Args: + group (Group): The group for which permissions are managed. + permissions (dict): A dictionary containing permission data. + + The `permissions` dictionary should have the following structure: + { + "permission_id_1": { + "status": True or False, # Indicates whether the permission + is granted or revoked. + "context": context_id or None # context associated with the permission. + }, + # Additional permission entries... + } + + For each permission in the dictionary, this function either grants or revokes + the permission for the group. + If "status" is True, the permission is granted. If "status" is False, + the permission is revoked. + The "context" field can specify additional context for the permission. + + Returns: + None + + This function performs the necessary operations on the `GroupPermission` model + based on the provided data. + It creates new permissions or deletes existing ones according to the specified status. + Any exceptions that occur during this process are printed to the console + for debugging purposes. + """ + for permission, permission_data in permissions.items(): + permission_id = int(permission) + context_id = permission_data.get('context', None) + status = permission_data.get('status', False) + try: + if status: + gp = GroupPermission.objects.filter(role=group, permission_id=permission_id + ).first() + if gp: + gp.context_id = context_id + gp.save() + else: + GroupPermission.objects.create(role=group, + permission_id=permission_id, + context_id=context_id) + else: + gp = GroupPermission.objects.get(role=group, + permission_id=permission_id, + context_id=context_id) + gp.delete() + except Exception as e: + print(f"\033[93mException ** {e}\033[0m") diff --git a/accounts/migrations/0005_context_permission_grouppermission_and_more.py b/accounts/migrations/0005_context_permission_grouppermission_and_more.py new file mode 100644 index 0000000..75d9903 --- /dev/null +++ b/accounts/migrations/0005_context_permission_grouppermission_and_more.py @@ -0,0 +1,140 @@ +# Generated by Django 4.2.5 on 2023-10-09 08:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("accounts", "0004_alter_location_pincode_alter_user_email_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Context", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, unique=True)), + ("description", models.TextField()), + ("order", models.IntegerField()), + ], + options={ + "ordering": ["order"], + }, + ), + migrations.CreateModel( + name="Permission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, unique=True)), + ( + "resource", + models.CharField( + choices=[ + ("role", "role"), + ("training", "training"), + ("test", "test"), + ("gallery", "gallery"), + ("about", "about"), + ("contact", "contact"), + ("home", "home"), + ], + max_length=100, + ), + ), + ("description", models.TextField()), + ], + ), + migrations.CreateModel( + name="GroupPermission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "context", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="accounts.context", + ), + ), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="accounts.permission", + ), + ), + ( + "role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="auth.group" + ), + ), + ], + options={ + "ordering": ["role__name", "context__order"], + "unique_together": {("role", "permission")}, + }, + ), + migrations.CreateModel( + name="ContextAllowAssign", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "assignedLevel", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="assignedContext", + to="accounts.context", + ), + ), + ( + "assigningLevel", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="assigningContext", + to="accounts.context", + ), + ), + ], + options={ + "db_table": "accounts_context_allow_assign", + "ordering": ["assigningLevel__order", "assignedLevel__order"], + "unique_together": {("assigningLevel", "assignedLevel")}, + }, + ), + ] diff --git a/accounts/models.py b/accounts/models.py index c0e78c0..3239b39 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -3,8 +3,9 @@ from django.core.validators import RegexValidator from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import Group - from common.models import State, District, City, Language +from config import RESOURCES + MAX_CLASS = 12 CLASS_CHOICES = [(i, f"Class {i}") for i in range(1, MAX_CLASS+1)] @@ -254,3 +255,136 @@ class Message(models.Model): MessageType, on_delete=models.CASCADE, related_name='message_type' ) + + +# Revised models +class Context(models.Model): + """ + A context defines the level at which a role operates or an action is performed + within the system. Example: Organization, city, class levels. It includes a name, + description, and an order to define its level of hierarchy. + + Attributes: + name (str): The name of the context. + description (str): A detailed description of the context. + order (int): The level of hierarchy for the context. + + Meta: + ordering (list): The instances are ordered by their 'order' attribute in + ascending order. + + Methods: + __str__(): Returns a string representation of the context in the format + 'order - name'. + """ + name = models.CharField(max_length=100, null=False, unique=True) + description = models.TextField(null=False) + order = models.IntegerField(null=False) # Level of hierarchy + + def __str__(self): + return f"{self.order} - {self.name}" + + class Meta: + ordering = ['order'] + + +class ContextAllowAssign(models.Model): + """ + This model maintains a relationship between two Context instances, + indicating that one context can assign roles in another context. + + Attributes: + assigningLevel (ForeignKey): A ForeignKey relationship to the Context + instance that has the permission to assign roles. + assignedLevel (ForeignKey): A ForeignKey relationship to the Context + instance where roles can be assigned. + + Meta: + unique_together (list of str): The combination of 'assigningLevel' and + 'assignedLevel' must be unique. + ordering (list of str): The default ordering of ContextAllowAssign + instances is by the + 'assigningLevel__order' and 'assignedLevel__order' attributes. + db_table (str): The name of the database table for this model. + + Methods: + __str__(): Returns a string representation of the permission. + """ + assigningLevel = models.ForeignKey(Context, on_delete=models.CASCADE, + related_name='assigningContext') + assignedLevel = models.ForeignKey(Context, on_delete=models.CASCADE, + related_name='assignedContext') + + class Meta: + unique_together = ['assigningLevel', 'assignedLevel'] + ordering = ['assigningLevel__order', 'assignedLevel__order'] + db_table = 'accounts_context_allow_assign' + + def __str__(self): + return f"{self.assigningLevel} can assign roles at level: {self.assignedLevel} " + + +class Permission(models.Model): + """ + This model represents a permission within the system, which is used to + control access to various resources. + + Attributes: + name (str): The name of the permission. Example: 'Can add testimonials'. + resource (str): The resource to which the permission is associated. + Example: 'testimonials', 'trainings'. + description (str): A detailed description of the permission. + + Methods: + __str__(): Returns a string representation of the permission. + + """ + RESOURCES = RESOURCES + name = models.CharField(max_length=100, null=False, unique=True) + resource = models.CharField(max_length=100, null=False, choices=RESOURCES) + description = models.TextField(null=False) + + def __str__(self): + return f"{self.name}" + + +class GroupPermission(models.Model): + """ + This model defines that a specific role has a particular permission + within a specific context. + + Attributes: + role (ForeignKey to Group): The role (group) to which the permission + is assigned. + permission (ForeignKey to Permission): The permission being assigned to + the role. + context (ForeignKey to Context): The context in which the permission is + granted. + created (DateTimeField): The timestamp when this role-permission + association was created. + updated (DateTimeField): The timestamp when this role-permission + association was last updated. + + Meta: + unique_together (list of str): The combination of 'role' and + 'permission' must be unique. + ordering (list of str): The default ordering of GroupPermission instances is + by the 'role__name' and 'context__order' attributes. + + Methods: + __str__(): Returns a string representation of the role-permission relationship. + """ + role = models.ForeignKey(Group, on_delete=models.CASCADE) + permission = models.ForeignKey(Permission, on_delete=models.CASCADE) + context = models.ForeignKey(Context, on_delete=models.CASCADE) + # ToDo For more granular control? + # attribute = models.CharField(max_length=100, null=False) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ['role', 'permission'] + ordering = ['role__name', 'context__order'] + + def __str__(self): + return f"{self.role} has permission {self.permission} at level: {self.context}" diff --git a/accounts/urls.py b/accounts/urls.py index 3d8033c..32975ab 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,7 +4,8 @@ from accounts.api.user_api import CentralCoordinatorViewset, SchoolCoordinatorViewset, \ TeacherViewset, ParentViewset from rest_framework import routers -from .views import LogoutView, MessageWithinCommunity +from .views import LogoutView, MessageWithinCommunity, \ + PermissionsView, PermissionsDetailView, get_group_data app_name = "accounts" router = routers.DefaultRouter(trailing_slash=False) @@ -24,4 +25,10 @@ path('', include(router.urls)), path('logout/', LogoutView.as_view(), name='logout'), path('message-list', MessageWithinCommunity.as_view(), name='message-list'), + + # APIs for group permission form + path('api/get_group_permissions/', get_group_data, name='get_group_permissions'), + path('api/permissions', PermissionsView.as_view(), name='permissions-view'), + path('api/permissions/', PermissionsDetailView.as_view(), + name='permissions-detail-view'), ] diff --git a/accounts/views.py b/accounts/views.py index 8602f38..8d45a8a 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,12 +1,15 @@ # from django.shortcuts import render from rest_framework.views import APIView +from rest_framework.decorators import api_view from rest_framework.permissions import IsAuthenticated -from django.http import HttpResponse from rest_framework_simplejwt.tokens import RefreshToken from rest_framework.response import Response from rest_framework import status -from accounts.models import User, Condition, MessageType, Message +from accounts.models import User, Condition, MessageType, \ + Message, GroupPermission, Permission, Context from django.contrib.auth.models import Group +from django.http import HttpResponse, JsonResponse +from .helper import manage_group_permissions class LogoutView(APIView): @@ -61,3 +64,68 @@ def post(self, request): print('Not a valid sender!!') response = HttpResponse("Message Part") return response + + +@api_view(['GET']) +def get_group_data(request): + """Retrieve group-specific permissions and associated context data. + + This function takes a Django HTTP request object and returns a JSON response + containing permissions and context data for a specified group. + If the group has permission for a specific resource & context, the 'status' for that + resource will be set to True; otherwise, it will be False. + + Args: + request (HttpRequest): A Django HttpRequest object containing user request data. + + Returns: + JsonResponse: A JSON response containing permissions and context data. + """ + print(f"\033[91m ** {type(request)} \033[0m") + group_id = request.GET.get('group_id') + allowed_permissions = GroupPermission.objects.filter(role_id=group_id).values( + 'permission_id', 'context_id') + group_permissions = {item['permission_id']: item['context_id'] + for item in allowed_permissions} + context = context = list(Context.objects.values('id', 'name')) + permissions = {} + for item in Permission.objects.all(): + resource = item.resource + if resource not in permissions: + permissions[resource] = [] + permissions[resource].append({'id': item.id, + 'name': item.name, + 'status': (item.id in group_permissions), + 'context': group_permissions.get(item.id, 0)}) + data = {'permissions': permissions, 'context': context} + return JsonResponse(data, status=status.HTTP_200_OK) + + +class PermissionsView(APIView): + def post(self, request): + try: + data = request.data + permissions = data.get('permissions', {}) + group = Group.objects.create(name=data.get('name', '')) + manage_group_permissions(group, permissions) + return Response({'message': f"Group {group} added."}, + status=status.HTTP_201_CREATED) + except Exception as e: + return Response({'message': f"{e}"}, status=status.HTTP_400_BAD_REQUEST) + + +class PermissionsDetailView(APIView): + def patch(self, request, pk): + data = request.data + permissions = data.get('permissions', {}) + try: + group = Group.objects.get(id=pk) + name = data.get('name', None) + if name: + group.name = name + group.save() + manage_group_permissions(group, permissions) + return Response({'message': f"Group {group} modified."}, + status=status.HTTP_201_CREATED) + except Exception as e: + return Response({'message': f"{e}"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/config.py b/config.py index 51c64dd..9f4fd01 100644 --- a/config.py +++ b/config.py @@ -1 +1,8 @@ DEBUG = True +RESOURCES = [('role', 'role'), + ('training', 'training'), + ('test', 'test'), + ('gallery', 'gallery'), + ('about', 'about'), + ('contact', 'contact'), + ('home', 'home')] diff --git a/requests/accounts.rest b/requests/accounts.rest new file mode 100644 index 0000000..bac41b8 --- /dev/null +++ b/requests/accounts.rest @@ -0,0 +1,34 @@ +POST http://127.0.0.1:8000/accounts/api/permissions HTTP/1.1 +content-type: application/json + +{ +"name" : "Org Head", +"permissions" : { + "1" : { + "context" : 1, + "status" : true + }, +"2" : { + "context" : 3, + "status" : false + } +} +} + + +### +PATCH http://127.0.0.1:8000/accounts/api/permissions/5 HTTP/1.1 +Content-Type: application/json + +{ + "name": "school heads", + "permissions" : { + "1" : { + "context" : 1, + "status" : false + } +} +} +### +GET http://127.0.0.1:8000/accounts/api/get_group_permissions HTTP/1.1 +