diff --git a/eap_backend/eap_api/migrations/0001_initial.py b/eap_backend/eap_api/migrations/0001_initial.py index 664267aa..b9b2c9d4 100644 --- a/eap_backend/eap_api/migrations/0001_initial.py +++ b/eap_backend/eap_api/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 3.2.8 on 2022-05-12 10:56 +# Generated by Django 3.2.8 on 2022-05-19 08:55 +from django.conf import settings import django.contrib.auth.models import django.contrib.auth.validators from django.db import migrations, models @@ -16,6 +17,124 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name="EAPUser", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="""Designates that this user has all permissions, + without explicitly assigning them.""", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="""Required. 150 characters or fewer. + Letters, digits and @/./+/-/_ only.""", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="""Designates whether this user should be treated as + active. Unselect this instead of deleting accounts.""", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="""The groups this user belongs to. + A user will get permissions granted to their groups.""", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), migrations.CreateModel( name="AssuranceCase", fields=[ @@ -37,6 +156,15 @@ class Migration(migrations.Migration): blank=True, default=None, max_length=50, null=True ), ), + ( + "owner", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="cases", + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.CreateModel( @@ -207,7 +335,7 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name="Context", + name="EAPGroup", fields=[ ( "id", @@ -219,24 +347,25 @@ class Migration(migrations.Migration): ), ), ("name", models.CharField(max_length=200)), - ("short_description", models.CharField(max_length=1000)), - ("long_description", models.CharField(max_length=3000)), ("created_date", models.DateTimeField(auto_now_add=True)), ( - "goal", + "member", + models.ManyToManyField( + related_name="all_groups", to=settings.AUTH_USER_MODEL + ), + ), + ( + "owner", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="context", - to="eap_api.toplevelnormativegoal", + related_name="owned_groups", + to=settings.AUTH_USER_MODEL, ), ), ], - options={ - "abstract": False, - }, ), migrations.CreateModel( - name="EAPUser", + name="Context", fields=[ ( "id", @@ -247,123 +376,21 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("password", models.CharField(max_length=128, verbose_name="password")), - ( - "last_login", - models.DateTimeField( - blank=True, null=True, verbose_name="last login" - ), - ), - ( - "is_superuser", - models.BooleanField( - default=False, - help_text=( - "Designates that this user has all permissions " - "without explicitly assigning them." - ), - verbose_name="superuser status", - ), - ), - ( - "username", - models.CharField( - error_messages={ - "unique": "A user with that username already exists." - }, - help_text=( - "Required. 150 characters or fewer. " - "Letters, digits and @/./+/-/_ only." - ), - max_length=150, - unique=True, - validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() - ], - verbose_name="username", - ), - ), - ( - "first_name", - models.CharField( - blank=True, max_length=150, verbose_name="first name" - ), - ), - ( - "last_name", - models.CharField( - blank=True, max_length=150, verbose_name="last name" - ), - ), - ( - "email", - models.EmailField( - blank=True, max_length=254, verbose_name="email address" - ), - ), - ( - "is_staff", - models.BooleanField( - default=False, - help_text=( - "Designates whether the user can log into this " - "admin site." - ), - verbose_name="staff status", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text=( - "Designates whether this user should be treated as " - "active. Unselect this instead of deleting " - "accounts." - ), - verbose_name="active", - ), - ), - ( - "date_joined", - models.DateTimeField( - default=django.utils.timezone.now, verbose_name="date joined" - ), - ), - ( - "groups", - models.ManyToManyField( - blank=True, - help_text=( - "The groups this user belongs to. " - "A user will get all permissions granted to each of their " - "groups." - ), - related_name="user_set", - related_query_name="user", - to="auth.Group", - verbose_name="groups", - ), - ), + ("name", models.CharField(max_length=200)), + ("short_description", models.CharField(max_length=1000)), + ("long_description", models.CharField(max_length=3000)), + ("created_date", models.DateTimeField(auto_now_add=True)), ( - "user_permissions", - models.ManyToManyField( - blank=True, - help_text="Specific permissions for this user.", - related_name="user_set", - related_query_name="user", - to="auth.Permission", - verbose_name="user permissions", + "goal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="context", + to="eap_api.toplevelnormativegoal", ), ), ], options={ - "verbose_name": "user", - "verbose_name_plural": "users", "abstract": False, }, - managers=[ - ("objects", django.contrib.auth.models.UserManager()), - ], ), ] diff --git a/eap_backend/eap_api/migrations/0002_assurancecase_owner.py b/eap_backend/eap_api/migrations/0002_assurancecase_owner.py deleted file mode 100644 index 7537bb9d..00000000 --- a/eap_backend/eap_api/migrations/0002_assurancecase_owner.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.2.8 on 2022-05-12 14:14 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("eap_api", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="assurancecase", - name="owner", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="cases", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/eap_backend/eap_api/migrations/0002_auto_20220519_1546.py b/eap_backend/eap_api/migrations/0002_auto_20220519_1546.py new file mode 100644 index 00000000..19f02a04 --- /dev/null +++ b/eap_backend/eap_api/migrations/0002_auto_20220519_1546.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.8 on 2022-05-19 15:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("eap_api", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="assurancecase", + name="edit_groups", + field=models.ManyToManyField( + related_name="editable_cases", to="eap_api.EAPGroup" + ), + ), + migrations.AddField( + model_name="assurancecase", + name="view_groups", + field=models.ManyToManyField( + related_name="viewable_cases", to="eap_api.EAPGroup" + ), + ), + ] diff --git a/eap_backend/eap_api/migrations/0003_auto_20220520_0928.py b/eap_backend/eap_api/migrations/0003_auto_20220520_0928.py new file mode 100644 index 00000000..4ac4bbfd --- /dev/null +++ b/eap_backend/eap_api/migrations/0003_auto_20220520_0928.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.8 on 2022-05-20 09:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("eap_api", "0002_auto_20220519_1546"), + ] + + operations = [ + migrations.AlterField( + model_name="assurancecase", + name="edit_groups", + field=models.ManyToManyField( + blank=True, related_name="editable_cases", to="eap_api.EAPGroup" + ), + ), + migrations.AlterField( + model_name="assurancecase", + name="view_groups", + field=models.ManyToManyField( + blank=True, related_name="viewable_cases", to="eap_api.EAPGroup" + ), + ), + ] diff --git a/eap_backend/eap_api/models.py b/eap_backend/eap_api/models.py index 7e59a5d6..3f2c8b01 100644 --- a/eap_backend/eap_api/models.py +++ b/eap_backend/eap_api/models.py @@ -4,7 +4,7 @@ import datetime from enum import Enum -# Create your models here. +# Classes representing tables in the database for EAP app. class EAPUser(AbstractUser): @@ -12,6 +12,18 @@ def __str__(self): return self.email +class EAPGroup(models.Model): + name = models.CharField(max_length=200) + created_date = models.DateTimeField(auto_now_add=True) + owner = models.ForeignKey( + EAPUser, related_name="owned_groups", on_delete=models.CASCADE + ) + member = models.ManyToManyField(EAPUser, related_name="all_groups") + + def __str__(self): + return self.name + + class Shape(Enum): """ Enum class to hold the various shapes for the objects on @@ -49,6 +61,12 @@ class AssuranceCase(models.Model): owner = models.ForeignKey( EAPUser, related_name="cases", on_delete=models.CASCADE, null=True ) + edit_groups = models.ManyToManyField( + EAPGroup, related_name="editable_cases", blank=True + ) + view_groups = models.ManyToManyField( + EAPGroup, related_name="viewable_cases", blank=True + ) shape = None def __str__(self): diff --git a/eap_backend/eap_api/serializers.py b/eap_backend/eap_api/serializers.py index 3b78c06d..f025385e 100644 --- a/eap_backend/eap_api/serializers.py +++ b/eap_backend/eap_api/serializers.py @@ -2,6 +2,7 @@ from .models import ( AssuranceCase, EAPUser, + EAPGroup, TopLevelNormativeGoal, Context, SystemDescription, @@ -11,6 +12,49 @@ ) +class EAPUserSerializer(serializers.ModelSerializer): + all_groups = serializers.PrimaryKeyRelatedField( + many=True, read_only=True, required=False + ) + owned_groups = serializers.PrimaryKeyRelatedField( + many=True, read_only=True, required=False + ) + + class Meta: + model = EAPUser + fields = ( + "username", + "email", + "last_login", + "date_joined", + "is_staff", + "all_groups", + "owned_groups", + ) + + +class EAPGroupSerializer(serializers.ModelSerializer): + members = serializers.PrimaryKeyRelatedField( + source="member", many=True, queryset=EAPUser.objects.all() + ) + owner_id = serializers.PrimaryKeyRelatedField( + source="owner", queryset=EAPUser.objects.all() + ) + viewable_cases = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + editable_cases = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + + class Meta: + model = EAPGroup + fields = ( + "id", + "name", + "owner_id", + "members", + "viewable_cases", + "editable_cases", + ) + + class AssuranceCaseSerializer(serializers.ModelSerializer): goals = serializers.PrimaryKeyRelatedField(many=True, read_only=True) type = serializers.CharField(default="AssuranceCase", read_only=True) @@ -26,15 +70,11 @@ class Meta: "lock_uuid", "goals", "owner", + "edit_groups", + "view_groups", ) -class EAPUserSerializer(serializers.ModelSerializer): - class Meta: - model = EAPUser - fields = ("email", "last_login", "date_joined", "is_staff") - - class TopLevelNormativeGoalSerializer(serializers.ModelSerializer): assurance_case_id = serializers.PrimaryKeyRelatedField( source="assurance_case", queryset=AssuranceCase.objects.all() diff --git a/eap_backend/eap_api/urls.py b/eap_backend/eap_api/urls.py index 0dcb01d9..8fdd3487 100644 --- a/eap_backend/eap_api/urls.py +++ b/eap_backend/eap_api/urls.py @@ -5,8 +5,10 @@ urlpatterns = [ path("auth/", include("rest_auth.urls")), path("auth/register/", include("rest_auth.registration.urls")), - path("home", views.AssuranceView.as_view(), name="home"), - path("home_goals", views.GoalsView.as_view(), name="home"), + path("users/", views.user_list, name="user_list"), + path("users//", views.user_detail, name="user_detail"), + path("groups/", views.group_list, name="group_list"), + path("groups//", views.group_detail, name="group_detail"), path("cases/", views.case_list, name="case_list"), path("cases//", views.case_detail, name="case_detail"), path("goals/", views.goal_list, name="goal_list"), diff --git a/eap_backend/eap_api/view_utils.py b/eap_backend/eap_api/view_utils.py new file mode 100644 index 00000000..09f7ce7c --- /dev/null +++ b/eap_backend/eap_api/view_utils.py @@ -0,0 +1,316 @@ +import warnings +from django.http import JsonResponse +from .models import ( + EAPGroup, + AssuranceCase, + TopLevelNormativeGoal, + Context, + SystemDescription, + PropertyClaim, + EvidentialClaim, + Evidence, +) +from . import models +from .serializers import ( + AssuranceCaseSerializer, + TopLevelNormativeGoalSerializer, + ContextSerializer, + SystemDescriptionSerializer, + PropertyClaimSerializer, + EvidentialClaimSerializer, + EvidenceSerializer, +) + +TYPE_DICT = { + "assurance_case": { + "serializer": AssuranceCaseSerializer, + "model": AssuranceCase, + "children": ["goals"], + "fields": ("name", "description", "lock_uuid", "owner"), + }, + "goal": { + "serializer": TopLevelNormativeGoalSerializer, + "model": TopLevelNormativeGoal, + "children": ["context", "system_description", "property_claims"], + "fields": ("name", "short_description", "long_description", "keywords"), + "parent_types": [("assurance_case", False)], + }, + "context": { + "serializer": ContextSerializer, + "model": Context, + "children": [], + "fields": ("name", "short_description", "long_description"), + "parent_types": [("goal", False)], + }, + "system_description": { + "serializer": SystemDescriptionSerializer, + "model": SystemDescription, + "children": [], + "fields": ("name", "short_description", "long_description"), + "parent_types": [("goal", False)], + }, + "property_claim": { + "serializer": PropertyClaimSerializer, + "model": PropertyClaim, + "children": ["evidential_claims", "property_claims"], + "fields": ("name", "short_description", "long_description"), + "parent_types": [("goal", False), ("property_claim", False)], + }, + "evidential_claim": { + "serializer": EvidentialClaimSerializer, + "model": EvidentialClaim, + "children": ["evidence"], + "fields": ("name", "short_description", "long_description"), + "parent_types": [("property_claim", True)], + }, + "evidence": { + "serializer": EvidenceSerializer, + "model": Evidence, + "children": [], + "fields": ("name", "short_description", "long_description", "URL"), + "parent_types": [("evidential_claim", True)], + }, +} +# Pluralising the name of the type should be irrelevant. +for k, v in tuple(TYPE_DICT.items()): + TYPE_DICT[k + "s"] = v + + +def get_case_id(item): + """Return the id of the case in which this item is. Works for all item types.""" + # In some cases, when there's a ManyToManyField, instead of the parent item, we get + # an iterable that can potentially list all the parents. In that case, just pick the + # first. + if hasattr(item, "first"): + item = item.first() + if isinstance(item, models.AssuranceCase): + return item.id + for k, v in TYPE_DICT.items(): + if isinstance(item, v["model"]): + for parent_type, _ in v["parent_types"]: + parent = getattr(item, parent_type) + if parent is not None: + return get_case_id(parent) + # TODO This should probably be an error raise rather than a warning, but currently + # there are dead items in the database without parents which hit this branch. + msg = f"Can't figure out the case ID of {item}." + warnings.warn(msg) + return None + + +def filter_by_case_id(items, request): + """Filter an iterable of case items, based on whether they are in the case specified + in the request query string. + """ + if "case_id" in request.GET: + case_id = int(request.GET["case_id"]) + items = [g for g in items if get_case_id(g) == case_id] + return items + + +def make_summary(serialized_data): + """ + Take in a full serialized object, and return dict containing just + the id and the name + + Parameter: serialized_data, dict, or list of dicts + Returns: dict, or list of dicts, containing just "name" and "id" key/values. + """ + + def summarize_one(data): + if not ( + isinstance(data, dict) and "id" in data.keys() and "name" in data.keys() + ): + raise RuntimeError("Expected dictionary containing name and id") + return {"name": data["name"], "id": data["id"]} + + if isinstance(serialized_data, list): + return [summarize_one(sd) for sd in serialized_data] + else: + return summarize_one(serialized_data) + + +def get_json_tree(id_list, obj_type): + """ + Recursive function for populating the full JSON data for goals, used + in the case_detail view (i.e. one API call returns the full case data). + + Params + ====== + id_list: list of object_ids from the parent serializer + obj_type: key of the json object (also a key of 'TYPE_DICT') + + Returns + ======= + objs: list of json objects + """ + objs = [] + for obj_id in id_list: + obj = TYPE_DICT[obj_type]["model"].objects.get(pk=obj_id) + obj_serializer = TYPE_DICT[obj_type]["serializer"](obj) + obj_data = obj_serializer.data + for child_type in TYPE_DICT[obj_type]["children"]: + child_list = sorted(obj_data[child_type]) + obj_data[child_type] = get_json_tree(child_list, child_type) + objs.append(obj_data) + return objs + + +def save_json_tree(data, obj_type, parent_id=None, parent_type=None): + """Recursively write items in an assurance case tree. + + Create a new assurance case like the one described by data, including all + its items. + + Params + ====== + data: JSON for the assurance case and all its items. At the top level + includes the whole item tree, subtrees when recursing. + obj_type: Key of the json object (also a key of 'TYPE_DICT'). At the top + level this should be "assurance_case". + parent_id: None at the top level, id of the caller when recursing. + + Returns + ======= + objs: JsonResponse describing failure/success. + """ + # Create the top object in data. Only include some of the fields from data, + # so that e.g. the new object gets a unique ID even if `data` specifies an + # ID. + this_data = {k: data[k] for k in TYPE_DICT[obj_type]["fields"]} + if parent_id is not None and parent_type is not None: + for parent_type_tmp, plural in TYPE_DICT[obj_type]["parent_types"]: + # TODO This is silly. It's all because some parent_type names are written + # with a plural s in the end while others are not. + if ( + parent_type not in parent_type_tmp + and parent_type_tmp not in parent_type + ): + continue + if plural: + parent_id = [parent_id] + this_data[parent_type_tmp + "_id"] = parent_id + serializer_class = TYPE_DICT[obj_type]["serializer"] + serializer = serializer_class(data=this_data) + if serializer.is_valid(): + serializer.save() + else: + return JsonResponse(serializer.errors, status=400) + + # Recurse into child types. + name = serializer.data["name"] + id = serializer.data["id"] + success_http_code = 201 + child_types = TYPE_DICT[obj_type]["children"] + for child_type in child_types: + if child_type not in data: + continue + for child_data in data[child_type]: + retval = save_json_tree( + child_data, child_type, parent_id=id, parent_type=obj_type + ) + # If one of the subcalls returns an error, return. + if retval.status_code != success_http_code: + return retval + + summary = {"name": name, "id": id} + return JsonResponse(summary, status=success_http_code) + + +def get_case_permissions(case, user): + """ + See if the user is allowed to view or edit the case. + + Params: + ======= + case: AssuranceCase instance, as obtained from AssuranceCase.objects.get(pk) + user: EAPUser instance, as returned from request.user + + Returns: + ======= + string + "manage": if case has no owner or if user is owner + "edit": if user is a member of a group that has edit rights on the case + "view": if user is a member of a group that has view rights on the case + None otherwise. + """ + if (not case.owner) or (case.owner == user): + # case has no owner - anyone can view it, or user is owner + return "manage" + + # now check groups + try: + user_groups = user.all_groups.get_queryset() + edit_groups = case.edit_groups.get_queryset() + # check intersection of two lists + if set(edit_groups) & set(user_groups): + return "edit" + view_groups = case.view_groups.get_queryset() + if set(view_groups) & set(user_groups): + return "view" + except EAPGroup.DoesNotExist: + # no group found for user or for case + pass + except AttributeError: + # probably AnonymousUser + pass + return None + + +def get_allowed_cases(user): + """ + get a list of AssuranceCases that the user is allowed to view or edit. + + Parameters: + =========== + user: EAPUser instance, as returned by request.user + + Returns: + ======== + list of AssuranceCase instances. + """ + all_cases = AssuranceCase.objects.all() + # if get_case_permissions returns anything other than None, include in allowed_cases + allowed_cases = [case for case in all_cases if get_case_permissions(case, user)] + return allowed_cases + + +def can_view_group(group, user, level="member"): + """ + See if the user is allowed to view the group, or if they own it + + Params: + ======= + group: EAPGroup instance, as obtained from EAPGroup.objects.get(pk) + user: EAPUser instance, as returned from request.user + level: str, either "member" or "owner", to select level of permission to view + + Returns: + ======= + True if user is member / owner of the group. + False otherwise. + """ + if level not in ["owner", "member"]: + raise RuntimeError("'level' parameter should be 'owner' or 'member'") + if level == "owner" and group.owner and group.owner == user: + return True + elif level == "member" and group in user.all_groups.get_queryset(): + return True + return False + + +def get_allowed_groups(user, level="member"): + """ + get a list of Groups that the user is allowed to view or that they own. + + Parameters: + =========== + user: EAPUser instance, as returned by request.user + level: str, either "member" or "owner", to select level of permission to view + + Returns: + ======== + list of EAPGroup instances in which the user is a member, or the owner + """ + all_groups = EAPGroup.objects.all() + return [group for group in all_groups if can_view_group(group, user, level)] diff --git a/eap_backend/eap_api/views.py b/eap_backend/eap_api/views.py index 0d5c34f3..0dc2a8f4 100644 --- a/eap_backend/eap_api/views.py +++ b/eap_backend/eap_api/views.py @@ -1,9 +1,10 @@ -import warnings from django.http import HttpResponse, JsonResponse from django.views.decorators.csrf import csrf_exempt from rest_framework.parsers import JSONParser -from rest_framework import generics +from rest_framework.decorators import api_view from .models import ( + EAPUser, + EAPGroup, AssuranceCase, TopLevelNormativeGoal, Context, @@ -12,8 +13,9 @@ EvidentialClaim, Evidence, ) -from . import models from .serializers import ( + EAPUserSerializer, + EAPGroupSerializer, AssuranceCaseSerializer, TopLevelNormativeGoalSerializer, ContextSerializer, @@ -22,225 +24,125 @@ EvidentialClaimSerializer, EvidenceSerializer, ) +from .view_utils import ( + filter_by_case_id, + make_summary, + get_json_tree, + save_json_tree, + get_case_permissions, + get_allowed_cases, + can_view_group, + get_allowed_groups, + TYPE_DICT, +) + -TYPE_DICT = { - "assurance_case": { - "serializer": AssuranceCaseSerializer, - "model": AssuranceCase, - "children": ["goals"], - "fields": ("name", "description", "lock_uuid", "owner"), - }, - "goal": { - "serializer": TopLevelNormativeGoalSerializer, - "model": TopLevelNormativeGoal, - "children": ["context", "system_description", "property_claims"], - "fields": ("name", "short_description", "long_description", "keywords"), - "parent_types": [("assurance_case", False)], - }, - "context": { - "serializer": ContextSerializer, - "model": Context, - "children": [], - "fields": ("name", "short_description", "long_description"), - "parent_types": [("goal", False)], - }, - "system_description": { - "serializer": SystemDescriptionSerializer, - "model": SystemDescription, - "children": [], - "fields": ("name", "short_description", "long_description"), - "parent_types": [("goal", False)], - }, - "property_claim": { - "serializer": PropertyClaimSerializer, - "model": PropertyClaim, - "children": ["evidential_claims", "property_claims"], - "fields": ("name", "short_description", "long_description"), - "parent_types": [("goal", False), ("property_claim", False)], - }, - "evidential_claim": { - "serializer": EvidentialClaimSerializer, - "model": EvidentialClaim, - "children": ["evidence"], - "fields": ("name", "short_description", "long_description"), - "parent_types": [("property_claim", True)], - }, - "evidence": { - "serializer": EvidenceSerializer, - "model": Evidence, - "children": [], - "fields": ("name", "short_description", "long_description", "URL"), - "parent_types": [("evidential_claim", True)], - }, -} -# Pluralising the name of the type should be irrelevant. -for k, v in tuple(TYPE_DICT.items()): - TYPE_DICT[k + "s"] = v - - -class AssuranceView(generics.ListCreateAPIView): - queryset = AssuranceCase.objects.all() - serializer_class = AssuranceCaseSerializer - - -class GoalsView(generics.ListCreateAPIView): - queryset = TopLevelNormativeGoal.objects.all() - serializer_class = TopLevelNormativeGoalSerializer - - -class DetailAssuranceView(generics.RetrieveUpdateDestroyAPIView): - queryset = AssuranceCase.objects.all() - serializer_class = AssuranceCaseSerializer - - -def get_case_id(item): - """Return the id of the case in which this item is. Works for all item types.""" - # In some cases, when there's a ManyToManyField, instead of the parent item, we get - # an iterable that can potentially list all the parents. In that case, just pick the - # first. - if hasattr(item, "first"): - item = item.first() - if isinstance(item, models.AssuranceCase): - return item.id - for k, v in TYPE_DICT.items(): - if isinstance(item, v["model"]): - for parent_type, _ in v["parent_types"]: - parent = getattr(item, parent_type) - if parent is not None: - return get_case_id(parent) - # TODO This should probably be an error raise rather than a warning, but currently - # there are dead items in the database without parents which hit this branch. - msg = f"Can't figure out the case ID of {item}." - warnings.warn(msg) - return None - - -def filter_by_case_id(items, request): - """Filter an iterable of case items, based on whether they are int he case specified - in the request query string. - """ - if "case_id" in request.GET: - case_id = int(request.GET["case_id"]) - items = [g for g in items if get_case_id(g) == case_id] - return items - - -def make_summary(serialized_data): - """ - Take in a full serialized object, and return dict containing just - the id and the name - - Parameter: serialized_data, dict, or list of dicts - Returns: dict, or list of dicts, containing just "name" and "id" key/values. - """ - - def summarize_one(data): - if not ( - isinstance(data, dict) and "id" in data.keys() and "name" in data.keys() - ): - raise RuntimeError("Expected dictionary containing name and id") - return {"name": data["name"], "id": data["id"]} - - if isinstance(serialized_data, list): - return [summarize_one(sd) for sd in serialized_data] - else: - return summarize_one(serialized_data) - - -def get_json_tree(id_list, obj_type): - """ - Recursive function for populating the full JSON data for goals, used - in the case_detail view (i.e. one API call returns the full case data). - - Params - ====== - id_list: list of object_ids from the parent serializer - obj_type: key of the json object (also a key of 'TYPE_DICT') - - Returns - ======= - objs: list of json objects - """ - objs = [] - for obj_id in id_list: - obj = TYPE_DICT[obj_type]["model"].objects.get(pk=obj_id) - obj_serializer = TYPE_DICT[obj_type]["serializer"](obj) - obj_data = obj_serializer.data - for child_type in TYPE_DICT[obj_type]["children"]: - child_list = sorted(obj_data[child_type]) - obj_data[child_type] = get_json_tree(child_list, child_type) - objs.append(obj_data) - return objs - - -def save_json_tree(data, obj_type, parent_id=None, parent_type=None): - """Recursively write items in an assurance case tree. - - Create a new assurance case like the one described by data, including all - its items. - - Params - ====== - data: JSON for the assurance case and all its items. At the top level - includes the whole item tree, subtrees when recursing. - obj_type: Key of the json object (also a key of 'TYPE_DICT'). At the top - level this should be "assurance_case". - parent_id: None at the top level, id of the caller when recursing. - - Returns - ======= - objs: JsonResponse describing failure/success. - """ - # Create the top object in data. Only include some of the fields from data, - # so that e.g. the new object gets a unique ID even if `data` specifies an - # ID. - this_data = {k: data[k] for k in TYPE_DICT[obj_type]["fields"]} - if parent_id is not None and parent_type is not None: - for parent_type_tmp, plural in TYPE_DICT[obj_type]["parent_types"]: - # TODO This is silly. It's all because some parent_type names are written - # with a plural s in the end while others are not. - if ( - parent_type not in parent_type_tmp - and parent_type_tmp not in parent_type - ): - continue - if plural: - parent_id = [parent_id] - this_data[parent_type_tmp + "_id"] = parent_id - serializer_class = TYPE_DICT[obj_type]["serializer"] - serializer = serializer_class(data=this_data) - if serializer.is_valid(): - serializer.save() - else: +@csrf_exempt +def user_list(request): + """ + List all users, or make a new user + """ + if request.method == "GET": + users = EAPUser.objects.all() + serializer = EAPUserSerializer(users, many=True) + return JsonResponse(serializer.data, safe=False) + elif request.method == "POST": + data = JSONParser().parse(request) + serializer = EAPUserSerializer(data=data) + if serializer.is_valid(): + serializer.save() + return JsonResponse(serializer.data, status=201) return JsonResponse(serializer.errors, status=400) - # Recurse into child types. - name = serializer.data["name"] - id = serializer.data["id"] - success_http_code = 201 - child_types = TYPE_DICT[obj_type]["children"] - for child_type in child_types: - if child_type not in data: - continue - for child_data in data[child_type]: - retval = save_json_tree( - child_data, child_type, parent_id=id, parent_type=obj_type - ) - # If one of the subcalls returns an error, return. - if retval.status_code != success_http_code: - return retval - summary = {"name": name, "id": id} - return JsonResponse(summary, status=success_http_code) +@csrf_exempt +@api_view(["GET", "PUT", "DELETE"]) +def user_detail(request, pk): + """ + Retrieve, update, or delete a User by primary key + """ + try: + user = EAPUser.objects.get(pk=pk) + except EAPUser.DoesNotExist: + return HttpResponse(status=404) + if request.user != user: + return HttpResponse(status=403) + if request.method == "GET": + serializer = EAPUserSerializer(user) + user_data = serializer.data + return JsonResponse(user_data) + elif request.method == "PUT": + data = JSONParser().parse(request) + serializer = EAPUserSerializer(user, data=data, partial=True) + if serializer.is_valid(): + serializer.save() + return JsonResponse(serializer.data) + return JsonResponse(serializer.errors, status=400) + elif request.method == "DELETE": + user.delete() + return HttpResponse(status=204) @csrf_exempt +@api_view(["GET", "POST"]) +def group_list(request): + """ + List all group, or make a new group + """ + if request.method == "GET": + response_dict = {} + for level in ["owner", "member"]: + groups = get_allowed_groups(request.user, level) + serializer = EAPGroupSerializer(groups, many=True) + response_dict[level] = serializer.data + return JsonResponse(response_dict, safe=False) + elif request.method == "POST": + data = JSONParser().parse(request) + data["owner_id"] = request.user.id + data["members"] = [request.user.id] + serializer = EAPGroupSerializer(data=data) + if serializer.is_valid(): + serializer.save() + return JsonResponse(serializer.data, status=201) + return JsonResponse(serializer.errors, status=400) + + +@csrf_exempt +@api_view(["GET", "PUT", "DELETE"]) +def group_detail(request, pk): + """ + Retrieve, update, or delete a Group by primary key + """ + try: + group = EAPGroup.objects.get(pk=pk) + except EAPGroup.DoesNotExist: + return HttpResponse(status=404) + if not can_view_group(group, request.user, "owner"): + return HttpResponse(status=403) + if request.method == "GET": + serializer = EAPGroupSerializer(group) + group_data = serializer.data + return JsonResponse(group_data) + elif request.method == "PUT": + data = JSONParser().parse(request) + serializer = EAPGroupSerializer(group, data=data, partial=True) + if serializer.is_valid(): + serializer.save() + return JsonResponse(serializer.data) + return JsonResponse(serializer.errors, status=400) + elif request.method == "DELETE": + group.delete() + return HttpResponse(status=204) + + +@csrf_exempt +@api_view(["GET", "POST"]) def case_list(request): """ List all cases, or make a new case """ if request.method == "GET": - cases = AssuranceCase.objects.all() + cases = get_allowed_cases(request.user) serializer = AssuranceCaseSerializer(cases, many=True) summaries = make_summary(serializer.data) return JsonResponse(summaries, safe=False) @@ -251,6 +153,7 @@ def case_list(request): @csrf_exempt +@api_view(["GET", "POST", "PUT", "DELETE"]) def case_detail(request, pk): """ Retrieve, update, or delete an AssuranceCase, by primary key @@ -259,16 +162,19 @@ def case_detail(request, pk): case = AssuranceCase.objects.get(pk=pk) except AssuranceCase.DoesNotExist: return HttpResponse(status=404) - - if case.owner and case.owner != request.user: + permissions = get_case_permissions(case, request.user) + if not permissions: return HttpResponse(status=403) if request.method == "GET": serializer = AssuranceCaseSerializer(case) case_data = serializer.data goals = get_json_tree(case_data["goals"], "goals") case_data["goals"] = goals + case_data["permissions"] = permissions return JsonResponse(case_data) elif request.method == "PUT": + if permissions not in ["manage", "edit"]: + return HttpResponse(status=403) data = JSONParser().parse(request) serializer = AssuranceCaseSerializer(case, data=data, partial=True) if serializer.is_valid(): @@ -276,6 +182,8 @@ def case_detail(request, pk): return JsonResponse(serializer.data) return JsonResponse(serializer.errors, status=400) elif request.method == "DELETE": + if permissions not in ["manage", "edit"]: + return HttpResponse(status=403) case.delete() return HttpResponse(status=204) diff --git a/frontend/src/components/CaseContainer.js b/frontend/src/components/CaseContainer.js index f1c69953..f54c34fb 100644 --- a/frontend/src/components/CaseContainer.js +++ b/frontend/src/components/CaseContainer.js @@ -6,12 +6,18 @@ import { FormClose, ZoomIn, ZoomOut } from "grommet-icons"; import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; import { v4 as uuidv4 } from "uuid"; +import CasePermissionsManager from "./CasePermissionsManager.js"; import MermaidChart from "./Mermaid"; import EditableText from "./EditableText.js"; import ItemViewer from "./ItemViewer.js"; import ItemEditor from "./ItemEditor.js"; import ItemCreator from "./ItemCreator.js"; -import { getBaseURL, jsonToMermaid } from "./utils.js"; +import { + getBaseURL, + jsonToMermaid, + highlightNode, + removeHighlight, +} from "./utils.js"; import configData from "../config.json"; import "./CaseContainer.css"; @@ -23,6 +29,7 @@ class CaseContainer extends Component { showEditLayer: false, showCreateLayer: false, showConfirmDeleteLayer: false, + showCasePermissionLayer: false, loading: true, assurance_case: { id: 0, @@ -51,6 +58,8 @@ class CaseContainer extends Component { this.setState({ loading: true }); this.setState({ assurance_case: json_response, + editGroupsStr: JSON.stringify(json_response.editGroups), + viewGroupsStr: JSON.stringify(json_response.viewGroups), }); this.setState({ mermaid_md: jsonToMermaid(this.state.assurance_case), @@ -180,30 +189,50 @@ class CaseContainer extends Component { return this.fetchData(this.state.id); } - showViewLayer(e) { - // use the name of the node to derive the type and id of the item that - // was clicked on, and set the state accordingly. - // This will cause a new layer, showing the details of the selected node, - // to appear (the ItemViewer component) + showViewOrEditLayer(e) { let chunks = e.split("_"); if (chunks.length === 2) { let itemType = chunks[0]; let itemId = chunks[1]; - this.setState({ itemType: itemType, itemId: itemId }); + this.setState({ loading: true }); + + this.setState({ + mermaid_md: highlightNode( + this.state.mermaid_md, + this.state.itemType, + this.state.itemId + ), + }); + console.log("setting highlight?"); + this.setState({ loading: false }); + if (this.inEditMode()) { + this.showEditLayer(itemType, itemId); + } else { + this.showViewLayer(); + } } + } + + showViewLayer() { + // use the name of the node to derive the type and id of the item that + // was clicked on, and set the state accordingly. + // This will cause a new layer, showing the details of the selected node, + // to appear (the ItemViewer component) + console.log("in showViewLayer"); // Maybe this is unnecessary, to check that the itemType and itemId state is // set, but need to make sure showViewLayer isn't set prematurely. if (this.state.itemType && this.state.itemId) - this.setState({ showViewLayer: true }); + console.log("in showViewLayer setting showviewlayer to true"); + this.setState({ showViewLayer: true }); } - showEditLayer(itemType, itemId, event) { - event.preventDefault(); + showEditLayer(itemType, itemId) { + // event.preventDefault(); // this should be redundant, as the itemId and itemType should already // be set when showViewLayer is called, but they can't do any harm.. this.setState({ itemType: itemType, itemId: itemId }); - this.hideViewLayer(); + // this.hideViewLayer(); this.setState({ showEditLayer: true }); } @@ -222,11 +251,28 @@ class CaseContainer extends Component { this.setState({ showConfirmDeleteLayer: true }); } + showCasePermissionLayer(event) { + event.preventDefault(); + this.setState({ showCasePermissionLayer: true }); + } + + resetHighlight() { + this.setState({ loading: true }); + this.setState({ + mermaid_md: removeHighlight(this.state.mermaid_md), + }); + setTimeout(() => { + this.setState({ loading: false }); + }, 100); + } + hideViewLayer() { + this.resetHighlight(); this.setState({ showViewLayer: false }); } hideEditLayer() { + // this.resetHighlight(); this.setState({ showEditLayer: false, itemType: null, itemId: null }); } @@ -245,12 +291,19 @@ class CaseContainer extends Component { }); } + hideCasePermissionLayer() { + this.setState({ + showCasePermissionLayer: false, + }); + } + viewLayer() { + console.log("in viewLayer()"); return ( this.hideViewLayer()} onClickOutside={() => this.hideViewLayer()} > @@ -282,37 +335,37 @@ class CaseContainer extends Component { } editLayer() { + /// placeholder + // this.hideEditLayer()} + // onClickOutside={() => this.hideEditLayer()} + // > return ( - this.hideEditLayer()} - onClickOutside={() => this.hideEditLayer()} + - -