From b41e80a65521e5ecaf77e29e8c0da4c6ee4bb9a0 Mon Sep 17 00:00:00 2001 From: Martin Juhas <511735@mail.muni.cz> Date: Wed, 10 Apr 2024 11:48:34 +0200 Subject: [PATCH] Merge aai-dev to main --- .gitignore | 1 - aai/__init__.py | 0 aai/admin.py | 3 + aai/apps.py | 9 + aai/backend.py | 28 ++ aai/management/__init__.py | 0 aai/management/commands/__init__.py | 1 + aai/management/commands/assignperms.py | 78 ++++ aai/migrations/0001_initial.py | 24 ++ aai/migrations/__init__.py | 0 aai/models.py | 114 ++++++ aai/schema/__init__.py | 1 + aai/schema/mutation.py | 88 +++++ aai/tests/aai_utils_tests.py | 353 +++++++++++++++++ aai/tests/encryption_tests.py | 1 + aai/utils.py | 355 ++++++++++++++++++ aai/views.py | 3 + common_lib/graphql/__init__.py | 4 + common_lib/graphql/api/__init__.py | 2 +- common_lib/graphql/api/utils.py | 4 + common_lib/graphql/graphql_api_t_case.py | 13 +- common_lib/graphql/mutations.py | 53 +++ common_lib/schema_types.py | 67 ++++ exercise/lib/log_manager.py | 80 +++- .../migrations/0009_auto_20240227_1813.py | 37 ++ ...o_20240227_1813_0010_auto_20240221_1426.py | 14 + exercise/models.py | 13 + exercise/schema/mutation.py | 19 + exercise/schema/query.py | 32 +- exercise/serializers.py | 66 +++- exercise/subscription.py | 3 + exercise/tests/create_exercise_tests.py | 9 +- exercise/views.py | 17 +- .../migrations/0011_auto_20240227_1813.py | 57 +++ ...o_20240227_1813_0012_auto_20240227_1222.py | 14 + exercise_definition/models/models.py | 41 ++ exercise_definition/views.py | 7 + running_exercise/lib/email_client.py | 23 +- running_exercise/lib/inject_selector.py | 7 +- running_exercise/lib/team_action_handler.py | 11 +- .../migrations/0006_auto_20240227_1813.py | 37 ++ ...o_20240227_1813_0007_auto_20240227_1222.py | 14 + .../migrations/0009_auto_20240320_1646.py | 39 ++ .../migrations/0010_emailthread_exercise.py | 21 ++ .../0011_alter_emailthread_exercise.py | 20 + running_exercise/models.py | 49 ++- running_exercise/schema/mutation.py | 44 ++- running_exercise/schema/query.py | 82 +++- running_exercise/serializers.py | 47 ++- running_exercise/subscription.py | 31 +- running_exercise/tests/email_tests.py | 54 ++- running_exercise/tests/serializers_tests.py | 132 +++++++ running_exercise/views.py | 6 + ttxbackend/schema.py | 8 +- ttxbackend/settings.py | 21 +- ttxbackend/urls.py | 1 + user/__init__.py | 0 user/admin.py | 5 + user/apps.py | 21 ++ user/email/email_sender.py | 38 ++ user/graphql_inputs.py | 31 ++ user/lib/__init__.py | 2 + user/lib/user_uploader.py | 90 +++++ user/lib/user_validator.py | 163 ++++++++ user/migrations/0001_initial.py | 117 ++++++ user/migrations/0002_auto_20240320_1646.py | 36 ++ user/migrations/0003_alter_user_id.py | 19 + user/migrations/0004_auto_20240402_1408.py | 31 ++ user/migrations/0005_auto_20240402_1922.py | 26 ++ user/migrations/0006_auto_20240405_1401.py | 25 ++ user/migrations/0007_auto_20240405_1518.py | 22 ++ user/migrations/__init__.py | 0 user/models.py | 238 ++++++++++++ user/schema/__init__.py | 2 + user/schema/mutation.py | 237 ++++++++++++ user/schema/query.py | 73 ++++ user/schema/validators.py | 149 ++++++++ user/templates/welcome_email.html | 8 + user/tests/__init__.py | 0 user/tests/csv_onboarding_tests.py | 208 ++++++++++ user/tests/graphql_api_tests.py | 289 ++++++++++++++ user/tests/mutation_validators_tests.py | 127 +++++++ user/urls.py | 11 + user/views.py | 50 +++ 84 files changed, 4193 insertions(+), 83 deletions(-) create mode 100644 aai/__init__.py create mode 100644 aai/admin.py create mode 100644 aai/apps.py create mode 100644 aai/backend.py create mode 100644 aai/management/__init__.py create mode 100644 aai/management/commands/__init__.py create mode 100644 aai/management/commands/assignperms.py create mode 100644 aai/migrations/0001_initial.py create mode 100644 aai/migrations/__init__.py create mode 100644 aai/models.py create mode 100644 aai/schema/__init__.py create mode 100644 aai/schema/mutation.py create mode 100644 aai/tests/aai_utils_tests.py create mode 100644 aai/tests/encryption_tests.py create mode 100644 aai/utils.py create mode 100644 aai/views.py create mode 100644 exercise/migrations/0009_auto_20240227_1813.py create mode 100644 exercise/migrations/0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426.py create mode 100644 exercise_definition/migrations/0011_auto_20240227_1813.py create mode 100644 exercise_definition/migrations/0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222.py create mode 100644 running_exercise/migrations/0006_auto_20240227_1813.py create mode 100644 running_exercise/migrations/0008_merge_0006_auto_20240227_1813_0007_auto_20240227_1222.py create mode 100644 running_exercise/migrations/0009_auto_20240320_1646.py create mode 100644 running_exercise/migrations/0010_emailthread_exercise.py create mode 100644 running_exercise/migrations/0011_alter_emailthread_exercise.py create mode 100644 running_exercise/tests/serializers_tests.py create mode 100644 user/__init__.py create mode 100644 user/admin.py create mode 100644 user/apps.py create mode 100644 user/email/email_sender.py create mode 100644 user/graphql_inputs.py create mode 100644 user/lib/__init__.py create mode 100644 user/lib/user_uploader.py create mode 100644 user/lib/user_validator.py create mode 100644 user/migrations/0001_initial.py create mode 100644 user/migrations/0002_auto_20240320_1646.py create mode 100644 user/migrations/0003_alter_user_id.py create mode 100644 user/migrations/0004_auto_20240402_1408.py create mode 100644 user/migrations/0005_auto_20240402_1922.py create mode 100644 user/migrations/0006_auto_20240405_1401.py create mode 100644 user/migrations/0007_auto_20240405_1518.py create mode 100644 user/migrations/__init__.py create mode 100644 user/models.py create mode 100644 user/schema/__init__.py create mode 100644 user/schema/mutation.py create mode 100644 user/schema/query.py create mode 100644 user/schema/validators.py create mode 100644 user/templates/welcome_email.html create mode 100644 user/tests/__init__.py create mode 100644 user/tests/csv_onboarding_tests.py create mode 100644 user/tests/graphql_api_tests.py create mode 100644 user/tests/mutation_validators_tests.py create mode 100644 user/urls.py create mode 100644 user/views.py diff --git a/.gitignore b/.gitignore index cc802dec..0eaf6a04 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,3 @@ data # export/import export_import -logs.jsonl diff --git a/aai/__init__.py b/aai/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aai/admin.py b/aai/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/aai/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/aai/apps.py b/aai/apps.py new file mode 100644 index 00000000..bd5f7be7 --- /dev/null +++ b/aai/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class AaiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "aai" + + def ready(self): + import aai.management.commands.assignperms diff --git a/aai/backend.py b/aai/backend.py new file mode 100644 index 00000000..527a23f0 --- /dev/null +++ b/aai/backend.py @@ -0,0 +1,28 @@ +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth import get_user_model +from rest_framework.exceptions import AuthenticationFailed + +UserModel = get_user_model() + + +class CustomAuthBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + if username is None: + username = kwargs.get(UserModel.USERNAME_FIELD) + if username is None or password is None: + raise AuthenticationFailed("Username or password is missing") + try: + user = UserModel._default_manager.get_by_natural_key(username) + except UserModel.DoesNotExist: + # Run the default password hasher once to reduce the timing + # difference between an existing and a nonexistent user. + UserModel().set_password(password) + raise AuthenticationFailed("Incorrect username or password") + else: + if not user.check_password(password): + raise AuthenticationFailed("Incorrect username or password") + + if not self.user_can_authenticate(user): + return AuthenticationFailed("Your account has been blocked") + + return user diff --git a/aai/management/__init__.py b/aai/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aai/management/commands/__init__.py b/aai/management/commands/__init__.py new file mode 100644 index 00000000..87f235c0 --- /dev/null +++ b/aai/management/commands/__init__.py @@ -0,0 +1 @@ +default_app_config = "aai.apps.AaiConfig" diff --git a/aai/management/commands/assignperms.py b/aai/management/commands/assignperms.py new file mode 100644 index 00000000..35c77c42 --- /dev/null +++ b/aai/management/commands/assignperms.py @@ -0,0 +1,78 @@ +from typing import Any +import sys + +from django.core.management.base import BaseCommand +from django.contrib.auth.models import Permission, Group +from django.db.models.signals import post_migrate +from django.dispatch import receiver + +from aai.models import UserGroup, Perms + +migrated_apps = set() +required_migrations = {"user", "aai", "django.contrib.auth"} +DONE = False + + +# Due to unit tests pipeline this has to be temporarly automatic +@receiver(post_migrate) +def assign_perms_after_migration(sender, **kwargs): + global DONE, migrated_apps, required_migrations + if DONE: + return + + migrated_apps.add(sender.name) + if required_migrations.issubset(migrated_apps): + assign_perms() + DONE = True + + +def assign_perms(): + # Initialize groups + trainee, _ = Group.objects.get_or_create(name=UserGroup.TRAINEE) + instructor, _ = Group.objects.get_or_create(name=UserGroup.INSTRUCTOR) + _, _ = Group.objects.get_or_create(name=UserGroup.ADMIN) + + #### trainee permissions assignemnt #### + trainee_perm_names = [ + Perms.view_exercise.codename, + Perms.use_tool.codename, + Perms.send_email.codename, + Perms.view_trainee_info.codename, + Perms.view_email_info.codename, + Perms.view_email.codename, + Perms.manipulate_file.codename, + ] + trainee_perms = Permission.objects.filter(codename__in=trainee_perm_names) + trainee.permissions.add(*trainee_perms) + instructor.permissions.add(*trainee_perms) + trainee.save() + + #### instructor permissions assignment #### + instructor_perm_names = [ + Perms.update_exercise.codename, + Perms.update_definition.codename, + Perms.view_definition.codename, + Perms.view_category.codename, + Perms.view_milestone.codename, + Perms.send_injectselection.codename, + Perms.view_extendtool.codename, + Perms.view_injectselection.codename, + Perms.view_analytics.codename, + Perms.update_userassignment.codename, + Perms.view_user.codename, + Perms.update_user.codename, + ] + instructor_perms = Permission.objects.filter( + codename__in=instructor_perm_names + ) + instructor.permissions.add(*instructor_perms) + instructor.save() + + sys.stderr.write("Predefined groups and permissions have been set in DB\n") + + +class Command(BaseCommand): + help = "Populate database with groups and assigns permissions" + + def handle(self, *args: Any, **options: Any): + assign_perms() diff --git a/aai/migrations/0001_initial.py b/aai/migrations/0001_initial.py new file mode 100644 index 00000000..8f35be44 --- /dev/null +++ b/aai/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.24 on 2024-02-27 18:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Perms', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'permissions': [('update_exercise', 'Can access instructor tools for exercise manipulation'), ('view_exercise', 'Can view exercise and their info'), ('update_definition', 'Can add/delete/change definition'), ('view_definition', 'Can view definition'), ('view_category', 'Can view inject categories'), ('view_milestone', 'Can view milestones'), ('use_tool', 'Can use tool of the exercise'), ('send_injectselection', 'Can pick and send inject selection'), ('send_email', 'Can send email and execute email related operations'), ('view_trainee_info', 'Can view exercise info intedned to trainees (roles, tools...)'), ('view_extendtool', 'Can view extend tool (with responses)'), ('view_injectselection', 'Can view inject selecion options'), ('view_email_info', 'Can view info related to emails (contacts, addresses...)'), ('view_email', 'Can view email bodies and threads'), ('view_analytics', 'Can view data needed for analytics dashboard'), ('update_userassignment', 'Can (un)assign user to exercise or team'), ('view_user', 'Can view users in database'), ('manipulate_file', 'Can upload and download files during exercise'), ('update_user', 'Can add/remove/change user')], + 'default_permissions': (), + }, + ), + ] diff --git a/aai/migrations/__init__.py b/aai/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aai/models.py b/aai/models.py new file mode 100644 index 00000000..d08619b0 --- /dev/null +++ b/aai/models.py @@ -0,0 +1,114 @@ +from typing import Optional +from enum import Enum + +from django.db import models +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.models import Group + + +class UserGroup(str, Enum): + TRAINEE = "trainee" + INSTRUCTOR = "instructor" + ADMIN = "admin" + + @staticmethod + def from_str(group: str) -> Optional["UserGroup"]: + lowered_group = group.lower() + if lowered_group == "trainee" or lowered_group == "t": + return UserGroup.TRAINEE + elif lowered_group == "instructor" or lowered_group == "i": + return UserGroup.INSTRUCTOR + elif lowered_group == "admin" or lowered_group == "a": + return UserGroup.ADMIN + return None + + def __eq__(self, other): + if isinstance(other, Group): + return self.value == other.name + return super().__eq__(other) + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self) -> int: + return super().__hash__() + + +class NameHandler: + full_name: str + codename: str + + def __init__(self, full_name): + self.full_name = full_name + self.codename = self.full_name.split(".")[1] + + def __get__(self, instance, owner): + return self + + +class Perms(models.Model): + update_exercise = NameHandler("aai.update_exercise") + view_exercise = NameHandler("aai.view_exercise") + update_definition = NameHandler("aai.update_definition") + view_definition = NameHandler("aai.view_definition") + view_category = NameHandler("aai.view_category") + view_milestone = NameHandler("aai.view_milestone") + use_tool = NameHandler("aai.use_tool") + send_injectselection = NameHandler("aai.send_injectselection") + send_email = NameHandler("aai.send_email") + view_trainee_info = NameHandler("aai.view_trainee_info") + view_extendtool = NameHandler("aai.view_extendtool") + view_injectselection = NameHandler("aai.view_injectselection") + view_email_info = NameHandler("aai.view_email_info") + view_email = NameHandler("aai.view_email") + view_analytics = NameHandler("aai.view_analytics") + update_userassignment = NameHandler("aai.update_userassignment") + view_user = NameHandler("aai.view_user") + manipulate_file = NameHandler("aai.manipulate_file") + update_user = NameHandler("aai.update_user") + + class Meta: + default_permissions = () + permissions = [ + ( + "update_exercise", + "Can access instructor tools for exercise manipulation", + ), + ("view_exercise", "Can view exercise and their info"), + ("update_definition", "Can add/delete/change definition"), + ("view_definition", "Can view definition"), + ("view_category", "Can view inject categories"), + ("view_milestone", "Can view milestones"), + ("use_tool", "Can use tool of the exercise"), + ("send_injectselection", "Can pick and send inject selection"), + ( + "send_email", + "Can send email and execute email related operations", + ), + ( + "view_trainee_info", + "Can view exercise info intedned to trainees (roles, tools...)", + ), + ("view_extendtool", "Can view extend tool (with responses)"), + ("view_injectselection", "Can view inject selecion options"), + ( + "view_email_info", + "Can view info related to emails (contacts, addresses...)", + ), + ("view_email", "Can view email bodies and threads"), + ("view_analytics", "Can view data needed for analytics dashboard"), + ( + "update_userassignment", + "Can (un)assign user to exercise or team", + ), + ("view_user", "Can view users in database"), + ( + "manipulate_file", + "Can upload and download files during exercise", + ), + ("update_user", "Can add/remove/change user"), + ] + + @classmethod + def content_type(cls): + return ContentType.objects.get_for_model(Perms) diff --git a/aai/schema/__init__.py b/aai/schema/__init__.py new file mode 100644 index 00000000..4cd53dec --- /dev/null +++ b/aai/schema/__init__.py @@ -0,0 +1 @@ +from .mutation import Mutation diff --git a/aai/schema/mutation.py b/aai/schema/mutation.py new file mode 100644 index 00000000..ef12726c --- /dev/null +++ b/aai/schema/mutation.py @@ -0,0 +1,88 @@ +import graphene +from typing import Optional + +from django.contrib.auth import authenticate, logout, login +from django.conf import settings + +from common_lib.schema_types import UserType +from rest_framework.exceptions import AuthenticationFailed +from common_lib.exceptions import ValidationError +from user.email.email_sender import send_password_change_notification + + +class LoginMutation(graphene.Mutation): + class Arguments: + username = graphene.String(required=True) + password = graphene.String(required=True) + + user = graphene.Field(UserType) + + @classmethod + def mutate( + cls, + root, + info, + username: str, + password: str, + ) -> graphene.Mutation: + # raises exception when fail + user = authenticate(username=username, password=password) + login(info.context, user) + # TODO: add logging + # TODO: implement attempts limiter + return LoginMutation(user=user) + + +class LogoutMutation(graphene.Mutation): + logged_out = graphene.Boolean() + + @classmethod + def mutate(cls, root, info) -> graphene.Mutation: + logout(info.context) + return LogoutMutation(logged_out=True) + + +class PasswordChange(graphene.Mutation): + class Arguments: + old_password = graphene.String(required=True) + new_password = graphene.String(required=True) + new_password_repeat = graphene.String(required=True) + + password_changed = graphene.Boolean() + + @classmethod + def mutate( + cls, + root, + info, + old_password: str, + new_password: str, + new_password_repeat: str, + ) -> graphene.Mutation: + user = info.context.user + + if not user.check_password(old_password): + raise AuthenticationFailed("Old password does not match") + + if new_password != new_password_repeat: + raise ValidationError("New password does not match") + + if new_password == old_password: + raise ValidationError( + "New password must be different from old password" + ) + user.set_password(new_password) + user.save() + send_password_change_notification(user) + # TODO: add logging + return PasswordChange(password_changed=True) + + +class Mutation(graphene.ObjectType): + login = LoginMutation.Field(description="Mutation for logging in an user") + logout = LogoutMutation.Field( + description="Mutation for logging out an user" + ) + password_change = PasswordChange.Field( + description="Mutation for changing a user's password" + ) diff --git a/aai/tests/aai_utils_tests.py b/aai/tests/aai_utils_tests.py new file mode 100644 index 00000000..03853fe2 --- /dev/null +++ b/aai/tests/aai_utils_tests.py @@ -0,0 +1,353 @@ +import os +import shutil + +from django.conf import settings +from django.test import TestCase, override_settings +from django.core.exceptions import PermissionDenied +from rest_framework.test import APIRequestFactory +from django.contrib.auth.models import AnonymousUser, Permission + +from user.models import User +from common_lib.test_utils import ( + internal_create_exercise, + internal_upload_definition, +) +from aai.utils import ( + protected, + team_protected, + exercise_protected, + definition_protected, + thread_protected, + log_protected, + visibility_protected, + input_object_protected, +) +from aai.models import Perms +from running_exercise.models import ( + ToolDetails, + Tool, + Content, + ActionLog, + LogType, + EmailThread, + EmailParticipant, +) + +TEST_DATA_STORAGE = "aai_tests_test_data" + + +@override_settings( + DATA_STORAGE=TEST_DATA_STORAGE, + FILE_STORAGE=os.path.join(TEST_DATA_STORAGE, "files"), +) +class AaiUtilsTests(TestCase): + @classmethod + def tearDownClass(cls): + shutil.rmtree(settings.DATA_STORAGE) + super().tearDownClass() + + @classmethod + def setUpTestData(cls): + cls.definition = internal_upload_definition("base_definition") + cls.exercise = internal_create_exercise(cls.definition.id, 3) + cls.exercise2 = internal_create_exercise(cls.definition.id, 3) + + cls.trainee1 = User.objects.create_user( + username="trainee1@mail.com", password="password" + ) + cls.trainee1_1 = User.objects.create_user( + username="trainee1_1@mail.com", password="password" + ) + cls.trainee2 = User.objects.create_user( + username="trainee2@mail.com", password="password" + ) + cls.trainee3 = User.objects.create_user( + username="trainee3@mail.com", password="password" + ) + cls.instructor = User.objects.create_staffuser( + username="instructor@mail.com", password="password" + ) + cls.instructor2 = User.objects.create_staffuser( + username="instructor2@mail.com", password="password" + ) + cls.admin = User.objects.create_superuser( + username="admin@mail.com", password="password" + ) + + cls.factory = APIRequestFactory() + cls.assignUsers() + cls.assignPerms() + cls.createData() + + @classmethod + def assignUsers(cls): + # Assign trainees to teams + cls.team1 = cls.exercise.teams.all()[0] + cls.team2 = cls.exercise.teams.all()[1] + cls.team3 = cls.exercise.teams.all()[2] + + cls.trainee1.teams.add(cls.team1) + cls.trainee2.teams.add(cls.team1) + cls.trainee3.teams.add(cls.team3) + + # Assign instructor of exercise + cls.instructor.exercises.add(cls.exercise) + + # Assign definition to the instructor + cls.instructor.definitions.add(cls.definition) + + @classmethod + def assignPerms(cls): + p = Permission.objects.get(codename=Perms.view_exercise.codename) + cls.trainee1.group.permissions.add(p) + cls.instructor.group.permissions.add(p) + + p = Permission.objects.get(codename=Perms.update_exercise.codename) + cls.instructor.group.permissions.add(p) + + @classmethod + def createData(cls): + # create action log + tool: Tool = cls.definition.tools.first() + content = Content.objects.create(raw=tool.default_response, rendered="") + details = ToolDetails.objects.create( + tool=tool, + argument="argument", + content=content, + user=cls.trainee1, + ) + cls.log = ActionLog.objects.create( + type=LogType.get_type(details), + team=cls.team1, + details=details, + ) + + # create thread + cls.participant = EmailParticipant.objects.create( + exercise=cls.exercise, address="team1@mail.com", team=cls.team1 + ) + + cls.thread = EmailThread.objects.create( + subject="Test thread", + exercise=cls.exercise, + ) + cls.thread.participants.add(cls.participant) + + def dummy(self, request): + return 0 + + def team_dummy(self, request, team_id): + return 0 + + def exercise_dummy(self, request, exercise_id): + return 0 + + def definition_dummy(self, request, definition_id): + return 0 + + def log_dummy(self, request, log_id): + return 0 + + def thread_dummy(self, request, thread_id): + return 0 + + def visibility_dummy(self, request, visible_only): + return 0 + + def test_protected_admin(self): + decorated = protected("aai.non_existent")( + self.dummy + ) # admin has access + request = self.factory.request() + + request.user = self.trainee1 + with self.assertRaises(PermissionDenied): + decorated(request) + + request.user = self.instructor + with self.assertRaises(PermissionDenied): + decorated(request) + + request.user = self.admin + self.assertEqual(decorated(request), 0) + + def test_protected_instructor(self): + decorated = protected(Perms.update_exercise.full_name)(self.dummy) + request = self.factory.request() + + request.user = self.trainee1 + with self.assertRaises(PermissionDenied): + decorated(request) + + request.user = self.instructor + self.assertEqual(decorated(request), 0) + + request.user = self.admin + self.assertEqual(decorated(request), 0) + + def test_protected_trainee(self): + decorated = protected(Perms.view_exercise.full_name)(self.dummy) + request = self.factory.request() + + request.user = self.trainee1 + self.assertEqual(decorated(request), 0) + + request.user = self.instructor + self.assertEqual(decorated(request), 0) + + request.user = self.admin + self.assertEqual(decorated(request), 0) + + def test_protected_anonoymous(self): + decorated = protected(Perms.view_exercise.full_name)(self.dummy) + request = self.factory.request() + + request.user = AnonymousUser() + with self.assertRaises(PermissionDenied): + decorated(request) + + def test_team_protected(self): + request = self.factory.request() + decorated = team_protected(self.team_dummy) + + request.user = self.trainee1 + self.assertEqual(decorated(request, team_id=self.team1.id), 0) + with self.assertRaises(PermissionDenied): + decorated(request, team_id=self.team2.id) + + request.user = self.trainee2 + self.assertEqual(decorated(request, team_id=self.team1.id), 0) + with self.assertRaises(PermissionDenied): + decorated(request, team_id=self.team2.id) + + request.user = self.trainee3 + self.assertEqual(decorated(request, team_id=self.team3.id), 0) + with self.assertRaises(PermissionDenied): + decorated(request, team_id=self.team2.id) + + request.user = AnonymousUser() + with self.assertRaises(PermissionDenied): + decorated(request, team_id=self.team2.id) + + def test_exercise_protected(self): + request = self.factory.request() + decorated = exercise_protected(self.exercise_dummy) + + request.user = self.instructor + self.assertEqual(decorated(request, exercise_id=self.exercise.id), 0) + with self.assertRaises(PermissionDenied): + decorated(request, exercise_id=self.exercise2.id) + + request.user = self.admin + self.assertEqual(decorated(request, exercise_id=self.exercise.id), 0) + self.assertEqual(decorated(request, exercise_id=self.exercise2.id), 0) + + request.user = self.trainee1 + self.assertEqual(decorated(request, exercise_id=self.exercise.id), 0) + with self.assertRaises(PermissionDenied): + decorated(request, exercise_id=self.exercise2.id) + + request.user = AnonymousUser() + with self.assertRaises(PermissionDenied): + decorated(request, team_id=self.team2.id) + + def test_definition_protected(self): + request = self.factory.request() + decorated = definition_protected(self.definition_dummy) + + request.user = self.instructor + self.assertEqual( + decorated(request, definition_id=self.definition.id), 0 + ) # instructor with access to definition + + request.user = self.instructor2 + # instructor without access to definition + with self.assertRaises(PermissionDenied): + decorated(request, definition_id=self.definition.id) + + request.user = self.admin + # admin should see all definition + self.assertEqual( + decorated(request, definition_id=self.definition.id), 0 + ) + + request.user = self.trainee1 + # trainee should't see the definition + with self.assertRaises(PermissionDenied): + decorated(request, definition_id=self.definition.id) + + def test_log_protected(self): + request = self.factory.request() + decorated = log_protected(self.log_dummy) + + request.user = self.trainee1 + # trainee1 created log -> can access it + self.assertEqual(decorated(request, log_id=self.log.id), 0) + + request.user = self.trainee2 + # trainee1_1 is in the same team as trainee1 who created the log thus + # can read the log as well + self.assertEqual(decorated(request, log_id=self.log.id), 0) + + request.user = self.instructor + # instructor is assigned to the exercise where log was created thus can access it + self.assertEqual(decorated(request, log_id=self.log.id), 0) + + request.user = self.admin + # admin can see all logs + self.assertEqual(decorated(request, log_id=self.log.id), 0) + + request.user = self.trainee3 + # trainee3 belongs to different team of the exercise thus can't read other teams logs + with self.assertRaises(PermissionDenied): + decorated(request, log_id=self.log.id) + + request.user = self.instructor2 + # instructor2 is not assigned to the exercise thus can't access the log + with self.assertRaises(PermissionDenied): + decorated(request, log_id=self.log.id) + + def test_thread_protected(self): + request = self.factory.request() + decorated = thread_protected(self.thread_dummy) + + request.user = self.trainee1 + # trainee1 created thread thus can access it + self.assertEqual(decorated(request, thread_id=self.thread.id), 0) + + request.user = self.trainee2 + # trainee2 is in the same team as trainee1 thus can access it + self.assertEqual(decorated(request, thread_id=self.thread.id), 0) + + request.user = self.instructor + # instructor is assigned to the exercise thus can access all threads of exercise + self.assertEqual(decorated(request, thread_id=self.thread.id), 0) + + request.user = self.admin + # admin can access all thread + self.assertEqual(decorated(request, thread_id=self.thread.id), 0) + + request.user = self.instructor2 + # instructor2 is not assigned to the exercise where thread belongs thus cant access it + with self.assertRaises(PermissionDenied): + decorated(request, thread_id=self.thread.id) + + request.user = self.trainee3 + # trainee3 does not belong to any participating teams of the thread + with self.assertRaises(PermissionDenied): + decorated(request, thread_id=self.thread.id) + + def test_visible_protected(self): + request = self.factory.request() + decorated = visibility_protected(self.visibility_dummy) + + request.user = self.instructor + # instructor can see both visible only and all data + self.assertEqual(decorated(request, visible_only=False), 0) + self.assertEqual(decorated(request, visible_only=True), 0) + + request.user = self.trainee1 + # trainee can see only visible_only=True data + self.assertEqual(decorated(request, visible_only=True), 0) + with self.assertRaises(PermissionDenied): + decorated(request, visible_only=False) diff --git a/aai/tests/encryption_tests.py b/aai/tests/encryption_tests.py new file mode 100644 index 00000000..249de7c6 --- /dev/null +++ b/aai/tests/encryption_tests.py @@ -0,0 +1 @@ +# TODO after FE tests compatibility with current implementation diff --git a/aai/utils.py b/aai/utils.py new file mode 100644 index 00000000..bd4b73e3 --- /dev/null +++ b/aai/utils.py @@ -0,0 +1,355 @@ +from typing import Tuple +from graphene import ResolveInfo +from rest_framework.request import HttpRequest, Request +from typing import Union + +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import PermissionDenied + +from user.models import User +from aai.models import UserGroup +from exercise.models import Team, Exercise +from running_exercise.models import EmailThread, ActionLog, EmailParticipant +from common_lib.utils import get_model, InputObject +from running_exercise.graphql_inputs import ( + UseToolInput, + SendEmailInput, + SelectTeamInjectInput, +) +from exercise.graphql_inputs import CreateExerciseInput + + +def _get_user(*args, **kwargs) -> Union[AnonymousUser, User]: + """ + Gets user object from known request types. + + Args: + *args - non-keyworded arguments of request + **kwargs - keyword arguments of request + Returns: + Instance of User if request is authenticated else instance of AnonymousUser + """ + if len(args) == 0: + return AnonymousUser + + request = args[-1] # get request + + if isinstance(request, ResolveInfo): + return request.context.user + + if isinstance(request, HttpRequest) or isinstance(request, Request): + return request.user + return AnonymousUser + + +def _checked_get_user(*args, **kwargs) -> User: + """ + Helper function for retrieving user of the request with anonymity check. + + Returns: + Instance of User or raises an exception when user is anonymous. + """ + user = _get_user(*args, **kwargs) + if user is None or user.is_anonymous: + raise PermissionDenied( + "You have no access to this team specific endpoint." + ) + return user + + +def _retrieve_param_id(param_name: str, *args, **kwargs) -> Tuple[User, int]: + """ + Helper function for getting user and given id parameter. Checks whether + both user and id parameter were successfully retrieved. Otherwise raises + PermissionDenied exception. + """ + user = _checked_get_user(*args, **kwargs) + param_id = kwargs.get(param_name, None) + if param_id is None: + raise PermissionDenied("Permission denied") + return user, int(param_id) + + +def _get_param_id_from_input_obj( + input_obj: InputObject, *args, **kwargs +) -> int: + """ + Helper function for retrieving id parameter from the current input objects. + + Returns: + Id parameter needed for authorization from input object. + """ + id_parameter = None + if isinstance(input_obj, (SelectTeamInjectInput, UseToolInput)): + id_parameter = input_obj.team_id + + elif isinstance(input_obj, SendEmailInput): + thread = get_model(EmailThread, id=input_obj.thread_id) + id_parameter = EmailParticipant.objects.get( + address=input_obj.sender_address, exercise_id=thread.exercise_id + ).team_id + + elif isinstance(input_obj, CreateExerciseInput): + id_parameter = input_obj.definition_id + + if id_parameter is None: + raise ValueError("Unexpected input object argument") + return int(id_parameter) + + +def _check_team_presence(user: User, team_id: int) -> bool: + """ + Helper function for checking whether user is in the team or user is instructor + of the exercise where team belongs. + """ + if user.teams.filter(id=team_id).exists(): + # user is in team which he wants to get data of + return True + exercises = user.exercises.all() + # user is instructor of the exercise where given team belongs + return Team.objects.filter(exercise__in=exercises, id=team_id).exists() + + +def _check_exercise_presence(user: User, exercise_id) -> bool: + """ + Helper function for checking whether user is the instructor of the exercise + or belongs to some of the team of the exercise. + """ + if user.exercises.filter(id=exercise_id).exists(): + # user is instructor of the exercise + return True + # user is trainee of the exercise + return user.teams.filter(exercise_id=exercise_id).exists() + + +def _check_definition_presence(user: User, definition_id) -> bool: + """ + Helper function for checking whether user has access to definition. + """ + # user is maintainer of the definition + return user.definitions.filter(id=definition_id).exists() + + +def protected(required_permission: str): + """ + Decorator that checks whether user of request has required permission. + If not, PermissionDenied exception is raised. + + Args: + required_permission: name of the permission in form "app_label.codename" + """ + + def decorator(func): + def wrapper(*args, **kwargs): + user = _get_user(*args, **kwargs) + if user.is_anonymous: # protected allows only authenticated users + raise PermissionDenied("Permission denied") + + if user.has_perm(required_permission): + return func(*args, **kwargs) + raise PermissionDenied("Permission denied") + + return wrapper + + return decorator + + +def team_protected(func): + """ + Decorator that checks whether user of the request is in the team stated in + request (or is instructor of the exercise containing the team). + The function resolving the request, where this decorator is used, + MUST HAVE "team_id" parameter to work properly. + If no condition is met, PermissionDenied exception is raised. + """ + + def wrapper(*args, **kwargs): + user, team_id = _retrieve_param_id("team_id", *args, **kwargs) + if user.is_superuser: + return func(*args, **kwargs) + + if _check_team_presence(user, team_id): + return func(*args, **kwargs) + raise PermissionDenied("Permission denied") + + return wrapper + + +def exercise_protected(func): + """ + Decorator that checks whether user of the request is instructor of the + exercise stated in request or trainee in the team of the exercise. + The function resolving the request, where this decorator is used, + MUST HAVE "exercise_id" parameter to work properly. + If no condition is met, PermissionDenied exception is raised. + """ + + def wrapper(*args, **kwargs): + user, exercise_id = _retrieve_param_id("exercise_id", *args, **kwargs) + if user.is_superuser: + return func(*args, **kwargs) + + if _check_exercise_presence(user, exercise_id): + return func(*args, **kwargs) + raise PermissionDenied("Permission denied") + + return wrapper + + +def definition_protected(func): + """ + Decorator that checks whether user of the request is maintainer of the + definition stated in request. + The function resolving the request, where this decorator is used, + MUST HAVE "definition_id" parameter to work properly. + If no condition is met, PermissionDenied exception is raised. + """ + + def wrapper(*args, **kwargs): + user, definition_id = _retrieve_param_id( + "definition_id", *args, **kwargs + ) + if user.is_superuser: + return func(*args, **kwargs) + + if _check_definition_presence(user, definition_id): + return func(*args, **kwargs) + raise PermissionDenied("Permission denied") + + return wrapper + + +def log_protected(func): + """ + Decorator that checks whether user of the request is in the team where log + belongs or is instructor of the exercise containing the team of the log. + The function resolving the request, where this decorator is used, + MUST HAVE "log_id" parameter to work properly. + If no condition is met, PermissionDenied exception is raised. + """ + + def wrapper(*args, **kwargs): + user, log_id = _retrieve_param_id("log_id", *args, **kwargs) + if user.is_superuser: + return func(*args, **kwargs) + + team_id = int(get_model(ActionLog, id=log_id).team_id) + if _check_team_presence(user, team_id): + return func(*args, **kwargs) + raise PermissionDenied("Permission denied") + + return wrapper + + +def _input_object_protection( + user: User, param_id: int, input_obj: InputObject +) -> bool: + """ + Helper function for checking authorization restrications on input objects. + Returns: + True if one of the condition to access the data is met otherwise False. + """ + if isinstance( + input_obj, (UseToolInput, SelectTeamInjectInput, SendEmailInput) + ): + if isinstance(input_obj, UseToolInput): + exercise = get_model(Exercise, teams__in=[param_id]) + # check if user has access to given tool (is in exercise + # containing the tool) + if not exercise.definition.tools.filter(id=param_id).exists(): + return False + + if _check_team_presence(user, param_id): + return True + + elif isinstance(input_obj, CreateExerciseInput): + return _check_definition_presence(user, param_id) + return False + + +def input_object_protected(obj_name: str): + """ + Decorator for checking InputObject types evaluates whether user has access + to requested data based on the data from InputObject type. + Input objects MUST HAVE parmater with name given in "obj_name" parameter to + work properly. + Args: + obj_name: name of keyword argument of the input object of the resolver + """ + + def decorator(func): + def wrapper(*args, **kwargs): + user = _checked_get_user(*args, **kwargs) + if user.is_superuser: + return func(*args, **kwargs) + + input_obj = kwargs.get(obj_name, None) + if input_obj is None: + raise PermissionDenied("Permission denied") + + # create_exercise_input: definition_id + # other input objects: team_id + param_id = _get_param_id_from_input_obj(input_obj, *args, **kwargs) + + if _input_object_protection(user, param_id, input_obj): + return func(*args, **kwargs) + raise PermissionDenied("Permission denied") + + return wrapper + + return decorator + + +def thread_protected(func): + """ + Decorator that checks whether user of the request is in one of the teams + of the email thread or is instructor of the exercise containing the + email thread. + The function resolving the request, where this decorator is used, + MUST HAVE "thread_id" parameter to work properly. + If no condition is met, PermissionDenied exception is raised. + """ + + def wrapper(*args, **kwargs): + user, thread_id = _retrieve_param_id("thread_id", *args, **kwargs) + if user.is_superuser: + return func(*args, **kwargs) + + thread = get_model(EmailThread, id=thread_id) + + if user.exercises.filter(id=thread.exercise_id).exists(): + # user is instructor of the exercise with the thread + return func(*args, **kwargs) + + participant_teams = thread.participants.exclude( + team_id=None + ).values_list("team_id", flat=True) + if user.teams.filter(id__in=participant_teams).exists(): + # user is trainee of the team participating in the thread + return func(*args, **kwargs) + + raise PermissionDenied("Permission denied") + + return wrapper + + +def visibility_protected(func): + """ + Decorator that checks whether user of the request can ask for team non-visible + information (visible_only set to False means all information should be retrieved + and it is not allowed to TRAINEE). + The function resolving the request, where this decorator is used, + MUST HAVE "visible_only" parameter to work properly. + If no condition is met, PermissionDenied exception is raised. + """ + + def wrapper(*args, **kwargs): + user = _checked_get_user(*args, **kwargs) + visible_only = kwargs.get("visible_only", None) + + if not visible_only and user.group == UserGroup.TRAINEE: + # TRAINEE trying to obtain trainee-non-visible data + raise PermissionDenied("Permission denied") + return func(*args, **kwargs) + + return wrapper diff --git a/aai/views.py b/aai/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/aai/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/common_lib/graphql/__init__.py b/common_lib/graphql/__init__.py index 3ea832ee..64ad6796 100644 --- a/common_lib/graphql/__init__.py +++ b/common_lib/graphql/__init__.py @@ -18,5 +18,9 @@ from .mutations import ( DeleteExerciseAction, StopExerciseAction, DeleteDefinitionAction, + AssignUsersToTeamAction, + RemoveUsersFromTeamAction, + AddDefinitionAccessAction, + RemoveDefinitionAccessAction, ) from .graphql_api_t_case import GraphQLApiTestCase diff --git a/common_lib/graphql/api/__init__.py b/common_lib/graphql/api/__init__.py index 25907cf6..82e4bf52 100644 --- a/common_lib/graphql/api/__init__.py +++ b/common_lib/graphql/api/__init__.py @@ -1,4 +1,4 @@ from .api_action import APIAction from .graphql_query import GraphQLQuery from .response_field import ResponseField -from .utils import id_argument +from .utils import id_argument, id_list_argument diff --git a/common_lib/graphql/api/utils.py b/common_lib/graphql/api/utils.py index 527df72e..96b3c758 100644 --- a/common_lib/graphql/api/utils.py +++ b/common_lib/graphql/api/utils.py @@ -3,3 +3,7 @@ from typing import Tuple def id_argument(name: str) -> Tuple[str, str]: return f"{name}Id", "ID!" + + +def id_list_argument(name: str) -> Tuple[str, str]: + return f"{name}Ids", "[ID]!" diff --git a/common_lib/graphql/graphql_api_t_case.py b/common_lib/graphql/graphql_api_t_case.py index 6b6e9cc5..1fea4ce2 100644 --- a/common_lib/graphql/graphql_api_t_case.py +++ b/common_lib/graphql/graphql_api_t_case.py @@ -1,9 +1,10 @@ import json -from typing import List, Any, Dict +from typing import List, Any, Dict, Optional from graphene_django.utils import GraphQLTestCase, camelize from common_lib.graphql.api import APIAction +from user.models import User class GraphQLApiTestCase(GraphQLTestCase): @@ -20,7 +21,12 @@ class GraphQLApiTestCase(GraphQLTestCase): def setUp(self): self._variables = {} - def run_action(self, action: APIAction, should_throw: bool = False): + def run_action( + self, + action: APIAction, + should_throw: bool = False, + user: Optional[User] = None, + ): """ Run a single graphql query. `should_throw` is a flag that determines whether the query should result in an error or not. @@ -33,6 +39,9 @@ class GraphQLApiTestCase(GraphQLTestCase): ResponseField specified on the action. """ action.validate() + if user is not None: + self.client.force_login(user) + result = self.query( action.query_string(), variables=camelize(action.get_variables()), diff --git a/common_lib/graphql/mutations.py b/common_lib/graphql/mutations.py index 2273fece..6358ad57 100644 --- a/common_lib/graphql/mutations.py +++ b/common_lib/graphql/mutations.py @@ -5,6 +5,7 @@ from common_lib.graphql.api import ( APIAction, GraphQLQuery, id_argument, + id_list_argument, ResponseField, ) from common_lib.utils import create_input @@ -168,3 +169,55 @@ class DeleteDefinitionAction(APIAction): [id_argument("definition")], Fragments.operation_done(), ) + + +class AssignUsersToTeamAction(APIAction): + query_name = "assignUsersToTeam" + + def __init__(self, variables: Dict[str, Any]): + self.variables = variables + self.query = GraphQLQuery( + "mutation", + self.query_name, + [id_list_argument("user"), id_argument("team")], + Fragments.operation_done(), + ) + + +class RemoveUsersFromTeamAction(APIAction): + query_name = "removeUsersFromTeam" + + def __init__(self, variables: Dict[str, Any]): + self.variables = variables + self.query = GraphQLQuery( + "mutation", + self.query_name, + [id_list_argument("user"), id_argument("team")], + Fragments.operation_done(), + ) + + +class AddDefinitionAccessAction(APIAction): + query_name = "addDefinitionAccess" + + def __init__(self, variables: Dict[str, Any]): + self.variables = variables + self.query = GraphQLQuery( + "mutation", + self.query_name, + [id_list_argument("user"), id_argument("definition")], + Fragments.operation_done(), + ) + + +class RemoveDefinitionAccessAction(APIAction): + query_name = "removeDefinitionAccess" + + def __init__(self, variables: Dict[str, Any]): + self.variables = variables + self.query = GraphQLQuery( + "mutation", + self.query_name, + [id_list_argument("user"), id_argument("definition")], + Fragments.operation_done(), + ) diff --git a/common_lib/schema_types.py b/common_lib/schema_types.py index 105a8f72..c7ce1c00 100644 --- a/common_lib/schema_types.py +++ b/common_lib/schema_types.py @@ -30,6 +30,28 @@ from running_exercise.models import ( CustomInjectDetails, LogType, ) +from user.models import User, Tag, Group + + +class RestrictedUser(DjangoObjectType): + class Meta: + model = User + exclude = ("definitions", "exercises", "teams") + + +class RestrictedExercise(DjangoObjectType): + id = graphene.ID(source="id") + uuid = graphene.UUID(source="uuid") + + class Meta: + model = Exercise + fields = () + + +class RestrictedTeam(DjangoObjectType): + class Meta: + model = Team + fields = ("id", "exercise", "name", "role", "email_address") class ExerciseDefinitionType(graphene.ObjectType): @@ -51,6 +73,7 @@ class DefinitionType(DjangoObjectType): exclude = ["milestones"] tools = graphene.List(graphene.NonNull(ToolType), required=True) + user_set = graphene.List(RestrictedUser) class DefinitionRoleType(DjangoObjectType): @@ -64,12 +87,15 @@ class ExerciseType(DjangoObjectType): # change the definition field type to hide some definition fields definition = graphene.Field(ExerciseDefinitionType) + user_set = graphene.Field(RestrictedUser) class TeamType(DjangoObjectType): class Meta: model = Team + user_set = graphene.List(RestrictedUser) + class ContentType(DjangoObjectType): class Meta: @@ -90,6 +116,8 @@ class ToolDetailsType(DjangoObjectType): class Meta: model = ToolDetails + user = graphene.Field(RestrictedUser) + class InjectDetailsType(DjangoObjectType): class Meta: @@ -100,11 +128,15 @@ class CustomInjectDetailsType(DjangoObjectType): class Meta: model = CustomInjectDetails + user = graphene.Field(RestrictedUser) + class EmailType(DjangoObjectType): class Meta: model = Email + user = graphene.Field(RestrictedUser) + class ActionLogDetails(graphene.Union): class Meta: @@ -223,6 +255,41 @@ class GrapheneConfig(graphene.ObjectType): custom_email_suffix = graphene.String() +class UserType(DjangoObjectType): + definitions = graphene.List(ExerciseDefinitionType) + exercises = graphene.List(RestrictedExercise) + teams = graphene.List(RestrictedTeam) + group = graphene.String() + + class Meta: + model = User + exclude = ["password"] + + def resolve_definitions(self, info): + return self.definitions.all() + + def resolve_exercises(self, info): + return self.exercises.all() + + def resolve_teams(self, info): + return self.teams.all() + + def resolve_group(self, info): + return self.group + + +class TagType(DjangoObjectType): + class Meta: + model = Tag + exclude_fields = ("user_set",) + + +class GroupType(DjangoObjectType): + class Meta: + model = Group + exclude_fields = ("user_set",) + + class ExerciseEventTypeEnum(models.TextChoices): CREATE = "create" MODIFY = "modify" diff --git a/exercise/lib/log_manager.py b/exercise/lib/log_manager.py index 4a4077cf..931d8b43 100644 --- a/exercise/lib/log_manager.py +++ b/exercise/lib/log_manager.py @@ -1,7 +1,7 @@ import json import os import shutil -from typing import TextIO, TypeVar, Type, Collection +from typing import TextIO, TypeVar, Type, Collection, Dict, Optional from django.conf import settings from django.db.models import Model @@ -16,6 +16,7 @@ from exercise.serializers import ( TeamSerializer, TeamInjectStateSerializer, ExerciseSerializer, + InstructorSerializer, ) from exercise_definition.models import Feature, FileInfo from exercise_definition.serializers import ( @@ -40,12 +41,17 @@ ModelType = TypeVar("ModelType", bound=Model) def _serialize_data( - out: TextIO, serializer: Type[SerializerType], data: Collection[ModelType] + out: TextIO, + serializer: Type[SerializerType], + data: Collection[ModelType], + context: Optional[Dict[str, object]] = None, ) -> None: model_count = len(data) for i, model in enumerate(data): out.write( - json.dumps(serializer(model).data, indent=None, ensure_ascii=False) + json.dumps( + serializer(model, context=context).data, ensure_ascii=False + ) ) if i + 1 < model_count: out.write("\n") @@ -55,9 +61,13 @@ class TeamLogSerializer: team: Team team_log_path: str team_upload_path: str + context: Dict[str, object] - def __init__(self, team: Team, log_path: str) -> None: + def __init__( + self, team: Team, log_path: str, context: Dict[str, object] + ) -> None: self.team = team + self.context = context self.team_log_path = os.path.join(log_path, f"team-{team.id}") os.makedirs(self.team_log_path) @@ -81,7 +91,10 @@ class TeamLogSerializer: os.path.join(self.team_log_path, "action_logs.jsonl"), "w" ) as action_logs_file: _serialize_data( - action_logs_file, ActionLogSerializer, self.team.logs.all() + action_logs_file, + ActionLogSerializer, + self.team.logs.all(), + self.context, ) def serialize_milestone_states(self) -> None: @@ -102,7 +115,9 @@ class TeamLogSerializer: with open( os.path.join(self.team_log_path, "emails.jsonl"), "w" ) as emails_file: - _serialize_data(emails_file, EmailThreadSerializer, email_threads) + _serialize_data( + emails_file, EmailThreadSerializer, email_threads, self.context + ) def serialize_categories(self) -> None: with open( @@ -129,10 +144,21 @@ class TeamLogSerializer: class ExerciseLogSerializer: exercise: Exercise log_path: str + anonymize: bool + analytics_ids: Dict[str, int] + context: Dict[str, object] - def __init__(self, exercise: Exercise, log_path: str) -> None: + def __init__( + self, exercise: Exercise, log_path: str, anonymize: bool + ) -> None: self.exercise = exercise self.log_path = log_path + self.anonymize = anonymize + self.analytics_ids = self.generate_analytics_ids() + self.context = { + "anonymize": self.anonymize, + "analytics_ids": self.analytics_ids, + } def serialize_all(self) -> None: self.serialize_exercise() @@ -145,6 +171,7 @@ class ExerciseLogSerializer: self.serialize_email_participants() self.serialize_teams(with_emails) + self.serialize_instructors() self.serialize_file_infos() self.copy_definition_files() @@ -196,12 +223,28 @@ class ExerciseLogSerializer: def serialize_teams(self, with_emails: bool) -> None: with open(os.path.join(self.log_path, "teams.jsonl"), "w") as teams: - _serialize_data(teams, TeamSerializer, self.exercise.teams.all()) + _serialize_data( + teams, + TeamSerializer, + self.exercise.teams.all(), + context=self.context, + ) for team in self.exercise.teams.all(): - serializer = TeamLogSerializer(team, self.log_path) + serializer = TeamLogSerializer(team, self.log_path, self.context) serializer.serialize_all(with_emails) + def serialize_instructors(self) -> None: + with open( + os.path.join(self.log_path, "instructors.jsonl"), "w" + ) as instructors: + _serialize_data( + instructors, + InstructorSerializer, + [self.exercise], + context=self.context, + ) + def serialize_file_infos(self) -> None: with open( os.path.join(self.log_path, "file_infos.jsonl"), "w" @@ -226,17 +269,32 @@ class ExerciseLogSerializer: os.path.join(definition_files_path, str(file_info.id)), ) + def generate_analytics_ids(self) -> Dict[str, int]: + user_to_analytics_id: Dict[str, int] = {} + analytics_id = 1 + + for team in self.exercise.teams.all(): + for user in team.user_set.all(): + user_to_analytics_id[user.username] = analytics_id + analytics_id += 1 + + for instructor in self.exercise.instructors.all(): + user_to_analytics_id[instructor.user.username] = analytics_id + analytics_id += 1 + + return user_to_analytics_id + class LogManager: log_path: str archive_path: str exercise_log_serializer: ExerciseLogSerializer - def __init__(self, exercise_id: int) -> None: + def __init__(self, exercise_id: int, anonymize: bool) -> None: exercise = get_model(Exercise, id=exercise_id) self.log_path = os.path.join(settings.LOG_STORAGE, "tmp", "logs") self.exercise_log_serializer = ExerciseLogSerializer( - exercise, self.log_path + exercise, self.log_path, anonymize ) self.archive_path = os.path.join( settings.LOG_STORAGE, diff --git a/exercise/migrations/0009_auto_20240227_1813.py b/exercise/migrations/0009_auto_20240227_1813.py new file mode 100644 index 00000000..08086b38 --- /dev/null +++ b/exercise/migrations/0009_auto_20240227_1813.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.24 on 2024-02-27 18:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('exercise', '0008_rename_elapsed_time_exercise_elapsed_s'), + ] + + operations = [ + migrations.AlterModelOptions( + name='emailparticipant', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='exercise', + options={'default_permissions': (), 'ordering': ['id']}, + ), + migrations.AlterModelOptions( + name='injectcategory', + options={'default_permissions': (), 'ordering': ['category__time']}, + ), + migrations.AlterModelOptions( + name='milestonestate', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='team', + options={'default_permissions': (), 'ordering': ['id']}, + ), + migrations.AlterModelOptions( + name='teamstate', + options={'default_permissions': ()}, + ), + ] diff --git a/exercise/migrations/0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426.py b/exercise/migrations/0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426.py new file mode 100644 index 00000000..94a383fc --- /dev/null +++ b/exercise/migrations/0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.24 on 2024-03-07 12:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('exercise', '0009_auto_20240227_1813'), + ('exercise', '0010_auto_20240221_1426'), + ] + + operations = [ + ] diff --git a/exercise/models.py b/exercise/models.py index 8b6452bd..ed77f457 100644 --- a/exercise/models.py +++ b/exercise/models.py @@ -33,6 +33,7 @@ class Exercise(models.Model): class Meta: ordering = ["id"] + default_permissions = () def is_time_reached(self, minute: int) -> bool: return self.elapsed_s - minute * 60 >= 0 @@ -43,6 +44,9 @@ class TeamState(models.Model): Exercise, on_delete=models.CASCADE, related_name="team_states" ) + class Meta: + default_permissions = () + class Team(models.Model): exercise = models.ForeignKey( @@ -59,6 +63,7 @@ class Team(models.Model): class Meta: ordering = ["id"] + default_permissions = () class MilestoneState(models.Model): @@ -71,6 +76,9 @@ class MilestoneState(models.Model): reached = models.BooleanField(default=False) timestamp_reached = models.DateTimeField(null=True, blank=True) + class Meta: + default_permissions = () + class MilestoneStateHistory(models.Model): milestone_state = models.ForeignKey( @@ -83,6 +91,7 @@ class MilestoneStateHistory(models.Model): class Meta: ordering = ["timestamp_from"] + default_permissions = () class TeamInjectState(models.Model): @@ -108,6 +117,7 @@ class TeamInjectState(models.Model): class Meta: ordering = ["inject__time"] + default_permissions = () class EmailParticipant(models.Model): @@ -130,6 +140,9 @@ class EmailParticipant(models.Model): blank=True, ) + class Meta: + default_permissions = () + def is_definition(self) -> bool: return self.definition_address_id is not None diff --git a/exercise/schema/mutation.py b/exercise/schema/mutation.py index bfca640c..55650120 100644 --- a/exercise/schema/mutation.py +++ b/exercise/schema/mutation.py @@ -4,6 +4,15 @@ from common_lib.schema_types import ExerciseType from exercise.graphql_inputs import CreateExerciseInput from exercise.lib.exercise_manager import ExerciseManager from exercise_definition.lib.definition_manager import DefinitionManager +from aai.models import Perms +from aai.utils import ( + protected, + exercise_protected, + definition_protected, + input_object_protected, +) +from user.schema.validators import validate_instructor_assigning +from user.models import InstructorOfExercise class CreateExerciseMutation(graphene.Mutation): @@ -15,6 +24,8 @@ class CreateExerciseMutation(graphene.Mutation): ) @classmethod + @protected(Perms.update_exercise.full_name) + @input_object_protected("create_exercise_input") def mutate( cls, root, @@ -22,6 +33,10 @@ class CreateExerciseMutation(graphene.Mutation): create_exercise_input: CreateExerciseInput, ) -> graphene.Mutation: exercise = ExerciseManager.create_exercise(create_exercise_input) + + user = validate_instructor_assigning([info.context.user.id], exercise) + InstructorOfExercise.objects.create(user=user[0], exercise=exercise) + return CreateExerciseMutation(exercise=exercise) @@ -32,6 +47,8 @@ class DeleteExerciseMutation(graphene.Mutation): operation_done = graphene.Boolean() @classmethod + @protected(Perms.update_exercise.full_name) + @exercise_protected def mutate(cls, root, info, exercise_id: str): ExerciseManager.delete_exercise(int(exercise_id)) return DeleteExerciseMutation(operation_done=True) @@ -44,6 +61,8 @@ class DeleteDefinitionMutation(graphene.Mutation): operation_done = graphene.Boolean() @classmethod + @protected(Perms.update_definition.full_name) + @definition_protected def mutate(cls, root, info, definition_id: str): DefinitionManager.delete_definition(int(definition_id)) return DeleteDefinitionMutation(operation_done=True) diff --git a/exercise/schema/query.py b/exercise/schema/query.py index 1a16c085..2ceca7a9 100644 --- a/exercise/schema/query.py +++ b/exercise/schema/query.py @@ -19,6 +19,8 @@ from exercise_definition.models import ( FileInfo, Channel, ) +from aai.models import Perms, UserGroup +from aai.utils import protected, exercise_protected, definition_protected class Query(graphene.ObjectType): @@ -91,6 +93,7 @@ class Query(graphene.ObjectType): description="Retrieve all channels for an exercise", ) + @protected(Perms.view_exercise.full_name) def resolve_exercises( self, info, @@ -99,7 +102,15 @@ class Query(graphene.ObjectType): limit: Optional[int] = None, skip: Optional[int] = None, ) -> List[Exercise]: - exercises = Exercise.objects.all() + user = info.context.user + if user.group == UserGroup.TRAINEE: + exercises = Exercise.objects.filter(teams__users__user=user) + elif user.group == UserGroup.INSTRUCTOR: + exercises = user.exercises.all() + elif user.group == UserGroup.ADMIN: + exercises = Exercise.objects.all() + else: + return Exercise.objects.none() if running is not None: exercises = exercises.filter(running=running) @@ -115,15 +126,28 @@ class Query(graphene.ObjectType): return exercises + @protected(Perms.view_exercise.full_name) + @exercise_protected def resolve_exercise_id(self, info, exercise_id: str) -> Optional[Exercise]: return Exercise.objects.filter(id=exercise_id).first() + @protected(Perms.view_definition.full_name) def resolve_definitions(self, info) -> List[Definition]: - return Definition.objects.all() - + user = info.context.user + if user.group == UserGroup.INSTRUCTOR: + return Definition.objects.filter(maintainers__user=user) + elif user.group == UserGroup.ADMIN: + return Definition.objects.all() + else: # UserGroup.TRAINEE and unknown groups can't see definitions + return [] + + @protected(Perms.view_definition.full_name) + @definition_protected def resolve_definition(self, info, definition_id: str) -> Definition: return get_model(Definition, id=definition_id) + @protected(Perms.view_category.full_name) + @exercise_protected def resolve_auto_injects(self, info, exercise_id: str) -> List[Inject]: exercise = get_model( Exercise, @@ -134,6 +158,8 @@ class Query(graphene.ObjectType): definition_id=exercise.definition.id, auto=True ) + @protected(Perms.view_milestone.full_name) + @exercise_protected def resolve_milestones(self, info, exercise_id: str) -> List[Milestone]: exercise = get_model( Exercise, diff --git a/exercise/serializers.py b/exercise/serializers.py index c1b8ae7e..9a83b103 100644 --- a/exercise/serializers.py +++ b/exercise/serializers.py @@ -1,5 +1,12 @@ +from typing import List, Dict + from rest_framework.fields import CharField -from rest_framework.serializers import ModelSerializer, IntegerField +from rest_framework.serializers import ( + ModelSerializer, + IntegerField, + EmailField, + SerializerMethodField, +) from exercise.models import ( EmailParticipant, @@ -9,6 +16,12 @@ from exercise.models import ( Exercise, ) from exercise_definition.serializers import EmailAddressSerializer +from user.models import User + + +def _serialize_users(users: List[User], context: Dict[str, object]): + serializer = UserSerializer(users, many=True, context=context) + return serializer.data class ExerciseSerializer(ModelSerializer): @@ -24,12 +37,61 @@ class ExerciseSerializer(ModelSerializer): ] +class UserSerializer(ModelSerializer): + username = EmailField(required=False, allow_null=True) + analytics_id = SerializerMethodField() + + class Meta: + model = User + fields = ("username", "analytics_id") + + def get_analytics_id(self, instance) -> int: + analytics_ids = self.context.get("analytics_ids", None) + if analytics_ids is None: + raise ValueError("No analytics_ids provided to UserSerializer") + + return analytics_ids[instance.username] + + def to_representation(self, instance): + anonymize = self.context.get("anonymize", False) + data = super().to_representation(instance) + + if anonymize: + data["username"] = None + + return data + + +class InstructorSerializer(ModelSerializer): + instructors = SerializerMethodField() + + class Meta: + model = Exercise + fields = ["instructors"] + + def get_instructors(self, obj): + users = [instr.user for instr in obj.instructors.all()] + return _serialize_users(users, self.context) + + class TeamSerializer(ModelSerializer): team_id = IntegerField(source="id") + users = SerializerMethodField() class Meta: model = Team - fields = ["team_id", "name", "role", "exercise_id", "finish_time"] + fields = [ + "team_id", + "name", + "role", + "exercise_id", + "finish_time", + "users", + ] + + def get_users(self, obj): + users = obj.user_set.all() + return _serialize_users(users, self.context) class ParticipantSerializer(ModelSerializer): diff --git a/exercise/subscription.py b/exercise/subscription.py index 71da72a3..433a42d0 100644 --- a/exercise/subscription.py +++ b/exercise/subscription.py @@ -3,6 +3,8 @@ import graphene from common_lib.schema_types import ExerciseEventTypeEnum from common_lib.schema_types import ExerciseType +from aai.models import Perms +from aai.utils import protected NOTIFICATION_QUEUE_LIMIT = 64 @@ -14,6 +16,7 @@ class ExercisesSubscription(channels_graphql_ws.Subscription): event_type = graphene.Field(graphene.Enum.from_enum(ExerciseEventTypeEnum)) @staticmethod + @protected(Perms.view_exercise.full_name) def subscribe(root, info): return ["all"] diff --git a/exercise/tests/create_exercise_tests.py b/exercise/tests/create_exercise_tests.py index 310ddb21..70c8ac7f 100644 --- a/exercise/tests/create_exercise_tests.py +++ b/exercise/tests/create_exercise_tests.py @@ -12,6 +12,7 @@ from common_lib.test_utils import internal_upload_definition from common_lib.utils import get_model from exercise.models import Exercise from exercise_definition.models import Definition +from user.models import User TEST_DATA_STORAGE = "create_exercise_test_data" @@ -28,6 +29,12 @@ class CreateExerciseTestCase(GraphQLApiTestCase): def setUpTestData(cls): cls.roles_definition = internal_upload_definition("roles_definition") cls.emails_definition = internal_upload_definition("emails_definition") + cls.instructor = User.objects.create_staffuser( + username="ins@mail.com", password="ins" + ) + # Add definition access to instructor so he can use them in exercise creation + cls.instructor.definitions.add(cls.roles_definition) + cls.instructor.definitions.add(cls.emails_definition) @classmethod def tearDownClass(cls): @@ -37,7 +44,7 @@ class CreateExerciseTestCase(GraphQLApiTestCase): super().tearDownClass() def create_exercise(self, action: CreateExerciseAction) -> Exercise: - self.run_action(action) + self.run_action(action, user=self.instructor) exercise_id = action.result["exercise"]["id"] return get_model(Exercise, id=exercise_id) diff --git a/exercise/views.py b/exercise/views.py index 2523dea1..094f1f1c 100644 --- a/exercise/views.py +++ b/exercise/views.py @@ -14,22 +14,37 @@ from common_lib.responses import ( ) from exercise.lib.export_import import export_database, import_database from exercise.lib.log_manager import LogManager +from aai.utils import protected, exercise_protected +from aai.models import Perms class RetrieveExerciseLogsView(APIView): @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="anonymize", + in_=openapi.IN_QUERY, + type=openapi.TYPE_BOOLEAN, + description="Set to true for user anonymization otherwise leave empty", + ) + ], responses={ 200: file_response("Exported exercise logs"), 500: ERROR_RESPONSE, }, ) + @protected(Perms.view_analytics.full_name) + @exercise_protected def get(self, request, *args, **kwargs): """ Get logs of all teams in a game. """ exercise_id = self.kwargs.get("exercise_id") + anonymize = request.GET.get("anonymize") is not None force = request.GET.get("force") is not None - archive_path = LogManager(int(exercise_id)).create_exercise_logs(force) + archive_path = LogManager( + exercise_id=int(exercise_id), anonymize=anonymize + ).create_exercise_logs(force) return FileResponse(open(archive_path, "rb"), as_attachment=True) diff --git a/exercise_definition/migrations/0011_auto_20240227_1813.py b/exercise_definition/migrations/0011_auto_20240227_1813.py new file mode 100644 index 00000000..1cf77150 --- /dev/null +++ b/exercise_definition/migrations/0011_auto_20240227_1813.py @@ -0,0 +1,57 @@ +# Generated by Django 3.2.24 on 2024-02-27 18:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('exercise_definition', '0010_inject_subject'), + ] + + operations = [ + migrations.AlterModelOptions( + name='category', + options={'default_permissions': (), 'ordering': ['time']}, + ), + migrations.AlterModelOptions( + name='config', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='definition', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='emailaddress', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='emailtemplate', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='enabledfeatures', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='inject', + options={'default_permissions': (), 'ordering': ['name']}, + ), + migrations.AlterModelOptions( + name='milestone', + options={'default_permissions': (), 'ordering': ['id']}, + ), + migrations.AlterModelOptions( + name='response', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='role', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='tool', + options={'default_permissions': ()}, + ), + ] diff --git a/exercise_definition/migrations/0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222.py b/exercise_definition/migrations/0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222.py new file mode 100644 index 00000000..4ad5529c --- /dev/null +++ b/exercise_definition/migrations/0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.24 on 2024-03-07 12:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('exercise_definition', '0011_auto_20240227_1813'), + ('exercise_definition', '0012_auto_20240227_1222'), + ] + + operations = [ + ] diff --git a/exercise_definition/models/models.py b/exercise_definition/models/models.py index 389311b6..c02dd562 100644 --- a/exercise_definition/models/models.py +++ b/exercise_definition/models/models.py @@ -37,6 +37,9 @@ class Definition(models.Model): name = models.CharField(max_length=100) version = models.CharField(max_length=20) + class Meta: + default_permissions = () + class FileInfo(models.Model): id = models.UUIDField( @@ -54,6 +57,9 @@ class FileInfo(models.Model): null=True, ) + class Meta: + default_permissions = () + class Content(models.Model): raw = models.CharField(max_length=2000) @@ -66,6 +72,9 @@ class Content(models.Model): blank=True, ) + class Meta: + default_permissions = () + class Config(models.Model): exercise_duration = models.IntegerField() @@ -74,6 +83,9 @@ class Config(models.Model): Definition, on_delete=models.CASCADE, null=True, blank=True ) + class Meta: + default_permissions = () + def has_enabled(self, feature: Feature) -> bool: return self.enabled_features.filter(feature=feature).first() is not None @@ -84,6 +96,9 @@ class EnabledFeatures(models.Model): ) feature = models.CharField(max_length=100, choices=Feature.choices) + class Meta: + default_permissions = () + class Control(models.Model): milestone_condition = models.CharField(max_length=200) @@ -91,6 +106,9 @@ class Control(models.Model): deactivate_milestone = models.CharField(max_length=200) roles = models.CharField(max_length=200) + class Meta: + default_permissions = () + class Milestone(models.Model): name = models.CharField(max_length=500) @@ -104,6 +122,7 @@ class Milestone(models.Model): class Meta: ordering = ["id"] + default_permissions = () class EmailAddress(models.Model): @@ -124,6 +143,7 @@ class EmailAddress(models.Model): fields=["address", "definition_id"], name="unique_address" ) ] + default_permissions = () class EmailTemplate(models.Model): @@ -138,10 +158,16 @@ class EmailTemplate(models.Model): EmailAddress, on_delete=models.CASCADE, related_name="templates" ) + class Meta: + default_permissions = () + class Overlay(models.Model): duration = models.IntegerField() + class Meta: + default_permissions = () + class InjectTypes(models.TextChoices): INFO = "info" @@ -170,6 +196,7 @@ class InfoAlternative(models.Model): class Meta: ordering = ["name"] + default_permissions = () class EmailAlternative(models.Model): @@ -196,6 +223,7 @@ class EmailAlternative(models.Model): class Meta: ordering = ["name"] + default_permissions = () Alternative = Union["InfoAlternative", "EmailAlternative"] @@ -214,6 +242,7 @@ class Inject(models.Model): class Meta: ordering = ["time"] + default_permissions = () @property def alternatives(self): @@ -232,6 +261,9 @@ class Tool(models.Model): Definition, on_delete=models.CASCADE, related_name="tools" ) + class Meta: + default_permissions = () + def find_responses(self, argument: str) -> List["Response"]: responses = [] for response in self.responses.all(): @@ -273,6 +305,9 @@ class Response(models.Model): Tool, on_delete=models.CASCADE, related_name="responses" ) + class Meta: + default_permissions = () + def __eq__(self, other): if not isinstance(other, Response): return False @@ -296,6 +331,9 @@ class Role(models.Model): Definition, on_delete=models.CASCADE, related_name="roles" ) + class Meta: + default_permissions = () + class Channel(models.Model): name = models.CharField(max_length=200) @@ -303,3 +341,6 @@ class Channel(models.Model): definition = models.ForeignKey( Definition, on_delete=models.CASCADE, related_name="channels" ) + + class Meta: + default_permissions = () diff --git a/exercise_definition/views.py b/exercise_definition/views.py index 7bd968c9..960c175c 100644 --- a/exercise_definition/views.py +++ b/exercise_definition/views.py @@ -8,6 +8,9 @@ from rest_framework.views import APIView from common_lib.exceptions import ApiException from common_lib.responses import success_json_response, ERROR_RESPONSE from exercise_definition.lib import DefinitionUploader +from aai.models import Perms +from aai.utils import protected +from user.models import DefinitionAccess class UploadDefinitionView(APIView): @@ -35,6 +38,7 @@ class UploadDefinitionView(APIView): 500: ERROR_RESPONSE, }, ) + @protected(Perms.update_definition.full_name) def post(self, request: Request, *args, **kwargs): """Upload an exercise definition file and check it for correctness.""" uploaded_file = request.FILES.get("file") @@ -48,6 +52,9 @@ class UploadDefinitionView(APIView): raise ApiException("Invalid file type, must be zip") definition = DefinitionUploader(uploaded_file).upload(definition_name) + DefinitionAccess.objects.create( + user=request.user, definition=definition + ) return Response( { "status": "ok", diff --git a/running_exercise/lib/email_client.py b/running_exercise/lib/email_client.py index 6dfda3b7..357855e0 100644 --- a/running_exercise/lib/email_client.py +++ b/running_exercise/lib/email_client.py @@ -25,6 +25,7 @@ from running_exercise.models import ( Email, ActionLog, ) +from user.models import User def _update_thread_milestones(thread: EmailThread, control: Optional[Control]): @@ -65,14 +66,13 @@ def send_email( def _validate_participants( - participant_addresses: List[str], + participant_addresses: List[str], exercise: Exercise ) -> List[EmailParticipant]: if len(set(participant_addresses)) < 2: raise RunningExerciseOperationException( "A thread must have at least 2 participants" ) - exercise = get_model(Exercise, running=True) email_between_teams = exercise.config.has_enabled( Feature.EMAIL_BETWEEN_TEAMS ) @@ -137,11 +137,14 @@ def _activate_thread_definition_milestones( class EmailClient: @staticmethod def create_thread( - participant_addresses: List[str], subject: str + participant_addresses: List[str], subject: str, exercise_id: str ) -> EmailThread: - participants = _validate_participants(participant_addresses) + exercise = get_model(Exercise, id=exercise_id) + participants = _validate_participants(participant_addresses, exercise) - new_thread = EmailThread.objects.create(subject=subject) + new_thread = EmailThread.objects.create( + subject=subject, exercise=exercise + ) new_thread.participants.add(*participants) SubscriptionHandler.broadcast_email_thread( @@ -150,7 +153,9 @@ class EmailClient: return new_thread @staticmethod - def send_email(send_email_input: SendEmailInput) -> List[ActionLog]: + def send_email( + send_email_input: SendEmailInput, user: Optional[User] = None + ) -> List[ActionLog]: thread = get_model(EmailThread, id=int(send_email_input.thread_id)) sender = get_model( EmailParticipant, @@ -183,7 +188,7 @@ class EmailClient: ) email = Email.objects.create( - sender=sender, thread=thread, content=content + sender=sender, thread=thread, content=content, user=user ) return send_email(email=email, channel=channel) @@ -229,7 +234,9 @@ class EmailClient: return thread return EmailClient.create_thread( - [participant.address for participant in participants], subject + [participant.address for participant in participants], + subject, + team.exercise.id, ) @staticmethod diff --git a/running_exercise/lib/inject_selector.py b/running_exercise/lib/inject_selector.py index c66c1f83..d1df40e5 100644 --- a/running_exercise/lib/inject_selector.py +++ b/running_exercise/lib/inject_selector.py @@ -24,13 +24,17 @@ from running_exercise.models import ( CustomInjectDetails, Email, ) +from user.models import User CUSTOM_INJECT_SELECTION_ID = "-1" class InjectSelector: @staticmethod - def select_inject(select_team_inject_input: SelectTeamInjectInput): + def select_inject( + select_team_inject_input: SelectTeamInjectInput, + user: Optional[User] = None, + ): team = get_model(Team, id=select_team_inject_input.team_id) if select_team_inject_input.file_id != "": file_info = get_model(FileInfo, id=select_team_inject_input.file_id) @@ -108,6 +112,7 @@ class InjectSelector: else: details = CustomInjectDetails.objects.create( content=content, + user=user, ) for _ in range(select_team_inject_input.repeat + 1): create_action_log(team, details, channel) diff --git a/running_exercise/lib/team_action_handler.py b/running_exercise/lib/team_action_handler.py index e8888663..b1a2fa9d 100644 --- a/running_exercise/lib/team_action_handler.py +++ b/running_exercise/lib/team_action_handler.py @@ -26,6 +26,7 @@ from running_exercise.lib.utils import ( from running_exercise.models import ( ToolDetails, ) +from user.models import User def _parse_selected_tool(team: Team, tool_id: int) -> Tool: @@ -57,7 +58,11 @@ def _find_chosen_response( def _handle_chosen_response( - tool: Tool, response: Optional[Response], team: Team, argument: str + tool: Tool, + response: Optional[Response], + team: Team, + argument: str, + user: Optional[User] = None, ) -> None: if response is not None: update_milestones(team, response.control) @@ -70,6 +75,7 @@ def _handle_chosen_response( tool=tool, argument=argument, content=content, + user=user, ) channel = get_model( @@ -81,7 +87,7 @@ def _handle_chosen_response( class TeamActionHandler: @staticmethod def perform_action( - use_tool_input: UseToolInput, + use_tool_input: UseToolInput, user: Optional[User] = None ) -> None: team = get_model(Team, id=int(use_tool_input.team_id)) if not team.exercise.running: @@ -100,4 +106,5 @@ class TeamActionHandler: chosen_response, team, tool_argument, + user, ) diff --git a/running_exercise/migrations/0006_auto_20240227_1813.py b/running_exercise/migrations/0006_auto_20240227_1813.py new file mode 100644 index 00000000..bce240e3 --- /dev/null +++ b/running_exercise/migrations/0006_auto_20240227_1813.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.24 on 2024-02-27 18:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("running_exercise", "0005_rename_description_emailthread_subject"), + ] + + operations = [ + migrations.AlterModelOptions( + name="actionlog", + options={"default_permissions": (), "ordering": ["id"]}, + ), + migrations.AlterModelOptions( + name="email", + options={"default_permissions": ()}, + ), + migrations.AlterModelOptions( + name="emailthread", + options={"default_permissions": ()}, + ), + migrations.AlterModelOptions( + name="injectoption", + options={"default_permissions": (), "ordering": ["id"]}, + ), + migrations.AlterModelOptions( + name="injectselection", + options={"default_permissions": (), "ordering": ["id"]}, + ), + migrations.AlterModelOptions( + name="threadparticipant", + options={"default_permissions": ()}, + ), + ] diff --git a/running_exercise/migrations/0008_merge_0006_auto_20240227_1813_0007_auto_20240227_1222.py b/running_exercise/migrations/0008_merge_0006_auto_20240227_1813_0007_auto_20240227_1222.py new file mode 100644 index 00000000..0461423b --- /dev/null +++ b/running_exercise/migrations/0008_merge_0006_auto_20240227_1813_0007_auto_20240227_1222.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.24 on 2024-03-07 12:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('running_exercise', '0006_auto_20240227_1813'), + ('running_exercise', '0007_auto_20240227_1222'), + ] + + operations = [ + ] diff --git a/running_exercise/migrations/0009_auto_20240320_1646.py b/running_exercise/migrations/0009_auto_20240320_1646.py new file mode 100644 index 00000000..b996407c --- /dev/null +++ b/running_exercise/migrations/0009_auto_20240320_1646.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.24 on 2024-03-20 16:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('running_exercise', '0008_merge_0006_auto_20240227_1813_0007_auto_20240227_1222'), + ] + + operations = [ + migrations.AlterModelOptions( + name='actionlog', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='teamfile', + options={'default_permissions': ()}, + ), + migrations.AddField( + model_name='custominjectdetails', + name='user', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='email', + name='user', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='tooldetails', + name='user', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/running_exercise/migrations/0010_emailthread_exercise.py b/running_exercise/migrations/0010_emailthread_exercise.py new file mode 100644 index 00000000..d10eb773 --- /dev/null +++ b/running_exercise/migrations/0010_emailthread_exercise.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.24 on 2024-03-28 16:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('exercise', '0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426'), + ('running_exercise', '0009_auto_20240320_1646'), + ] + + operations = [ + migrations.AddField( + model_name='emailthread', + name='exercise', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='exercise', to='exercise.exercise'), + preserve_default=False, + ), + ] diff --git a/running_exercise/migrations/0011_alter_emailthread_exercise.py b/running_exercise/migrations/0011_alter_emailthread_exercise.py new file mode 100644 index 00000000..ad90aa50 --- /dev/null +++ b/running_exercise/migrations/0011_alter_emailthread_exercise.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.24 on 2024-04-05 13:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('exercise', '0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426'), + ('running_exercise', '0010_emailthread_exercise'), + ] + + operations = [ + migrations.AlterField( + model_name='emailthread', + name='exercise', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='threads', to='exercise.exercise'), + ), + ] diff --git a/running_exercise/models.py b/running_exercise/models.py index cab2db99..cf0f9f35 100644 --- a/running_exercise/models.py +++ b/running_exercise/models.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models -from exercise.models import Team, EmailParticipant, TeamInjectState +from exercise.models import Team, EmailParticipant, TeamInjectState, Exercise from exercise_definition.models import ( Tool, FileInfo, @@ -14,6 +14,7 @@ from exercise_definition.models import ( Channel, Overlay, ) +from user.models import User class ToolDetails(models.Model): @@ -28,6 +29,16 @@ class ToolDetails(models.Model): on_delete=models.CASCADE, related_name="+", ) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="+", + null=True, + default=None, + ) + + class Meta: + default_permissions = () class InjectDetails(models.Model): @@ -47,6 +58,9 @@ class InjectDetails(models.Model): null=True, ) + class Meta: + default_permissions = () + class CustomInjectDetails(models.Model): content = models.ForeignKey( @@ -54,6 +68,16 @@ class CustomInjectDetails(models.Model): on_delete=models.CASCADE, related_name="+", ) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="+", + null=True, + default=None, + ) + + class Meta: + default_permissions = () DetailsUnion = Union[ToolDetails, InjectDetails, CustomInjectDetails, "Email"] @@ -93,6 +117,9 @@ class ActionLog(models.Model): details_id = models.PositiveIntegerField() details = GenericForeignKey("content_type", "details_id") + class Meta: + default_permissions = () + class EmailThread(models.Model): subject = models.CharField(max_length=300) @@ -103,6 +130,12 @@ class EmailThread(models.Model): related_name="threads", ) timestamp = models.DateTimeField(auto_now_add=True) + exercise = models.ForeignKey( + Exercise, on_delete=models.CASCADE, related_name="threads" + ) + + class Meta: + default_permissions = () def get_definition_participant(self) -> Optional[EmailParticipant]: return self.participants.filter( @@ -128,6 +161,16 @@ class Email(models.Model): blank=True, null=True, ) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="+", + null=True, + default=None, + ) + + class Meta: + default_permissions = () class ThreadParticipant(models.Model): @@ -145,6 +188,7 @@ class ThreadParticipant(models.Model): fields=["email_participant", "email_thread"], ) ] + default_permissions = () class InjectSelection(models.Model): @@ -159,6 +203,7 @@ class InjectSelection(models.Model): class Meta: ordering = ["id"] + default_permissions = () class InjectOption(models.Model): @@ -176,6 +221,7 @@ class InjectOption(models.Model): class Meta: ordering = ["id"] + default_permissions = () class TeamFile(models.Model): @@ -193,3 +239,4 @@ class TeamFile(models.Model): fields=["team", "file_info"], ) ] + default_permissions = () diff --git a/running_exercise/schema/mutation.py b/running_exercise/schema/mutation.py index 7237738f..9df9072e 100644 --- a/running_exercise/schema/mutation.py +++ b/running_exercise/schema/mutation.py @@ -16,6 +16,13 @@ from running_exercise.lib.inject_selector import ( ) from running_exercise.lib.milestone_handler import instructor_modify_milestone from running_exercise.lib.team_action_handler import TeamActionHandler +from aai.models import Perms +from aai.utils import ( + protected, + exercise_protected, + team_protected, + input_object_protected, +) class UseToolMutation(graphene.Mutation): @@ -25,13 +32,15 @@ class UseToolMutation(graphene.Mutation): operation_done = graphene.Boolean() @classmethod + @protected(Perms.use_tool.full_name) + @input_object_protected("use_tool_input") def mutate( cls, root, info, use_tool_input: UseToolInput, ) -> graphene.Mutation: - TeamActionHandler.perform_action(use_tool_input) + TeamActionHandler.perform_action(use_tool_input, info.context.user) return UseToolMutation(operation_done=True) @@ -44,10 +53,14 @@ class SelectTeamInjectOptionMutation(graphene.Mutation): operation_done = graphene.Boolean() @classmethod + @protected(Perms.send_injectselection.full_name) + @input_object_protected("select_team_inject_input") def mutate( cls, root, info, select_team_inject_input: SelectTeamInjectInput ) -> graphene.Mutation: - InjectSelector.select_inject(select_team_inject_input) + InjectSelector.select_inject( + select_team_inject_input, info.context.user + ) return SelectTeamInjectOptionMutation(operation_done=True) @@ -65,9 +78,20 @@ class CreateThreadMutation(graphene.Mutation): thread = graphene.Field(EmailThreadType) @classmethod - def mutate(cls, root, info, participant_addresses: List[str], subject: str): + @protected(Perms.send_email.full_name) + @exercise_protected + def mutate( + cls, + root, + info, + participant_addresses: List[str], + subject: str, + exercise_id: str, + ): return CreateThreadMutation( - thread=EmailClient.create_thread(participant_addresses, subject) + thread=EmailClient.create_thread( + participant_addresses, subject, exercise_id + ) ) @@ -78,13 +102,15 @@ class SendEmailMutation(graphene.Mutation): operation_done = graphene.Boolean() @classmethod + @protected(Perms.send_email.full_name) + @input_object_protected("send_email_input") def mutate( cls, root, info, send_email_input: SendEmailInput, ): - EmailClient.send_email(send_email_input) + EmailClient.send_email(send_email_input, info.context.user) return SendEmailMutation(operation_done=True) @@ -96,6 +122,8 @@ class MoveExerciseTimeMutation(graphene.Mutation): exercise = graphene.Field(ExerciseType) @classmethod + @protected(Perms.update_exercise.full_name) + @exercise_protected def mutate(cls, root, info, exercise_id: str, time_diff: int): exercise = ExerciseLoop.move_time(int(exercise_id), time_diff) return MoveExerciseTimeMutation(exercise=exercise) @@ -108,6 +136,8 @@ class StartExerciseMutation(graphene.Mutation): exercise = graphene.Field(ExerciseType) @classmethod + @protected(Perms.update_exercise.full_name) + @exercise_protected def mutate(cls, root, info, exercise_id: str): exercise = ExerciseLoop.start(int(exercise_id)) return StartExerciseMutation(exercise=exercise) @@ -120,6 +150,8 @@ class StopExerciseMutation(graphene.Mutation): exercise = graphene.Field(ExerciseType) @classmethod + @protected(Perms.update_exercise.full_name) + @exercise_protected def mutate(cls, root, info, exercise_id: str): exercise = ExerciseLoop.stop(int(exercise_id)) return StopExerciseMutation(exercise=exercise) @@ -138,6 +170,8 @@ class ModifyMilestoneMutation(graphene.Mutation): operation_done = graphene.Boolean() @classmethod + @protected(Perms.update_exercise.full_name) + @team_protected def mutate(cls, root, info, team_id: str, milestone: str, activate: bool): instructor_modify_milestone( team_id=int(team_id), milestone=milestone, activate=activate diff --git a/running_exercise/schema/query.py b/running_exercise/schema/query.py index a18abe70..310314be 100644 --- a/running_exercise/schema/query.py +++ b/running_exercise/schema/query.py @@ -3,6 +3,15 @@ from typing import List import graphene from django.db.models import QuerySet +from aai.models import Perms +from aai.utils import ( + protected, + exercise_protected, + team_protected, + log_protected, + thread_protected, + visibility_protected, +) from common_lib.exceptions import RunningExerciseOperationException from common_lib.schema_types import ( ActionLogType, @@ -28,12 +37,7 @@ from running_exercise.lib.email_client import EmailClient from running_exercise.lib.exercise_loop import ExerciseLoop from running_exercise.lib.milestone_handler import get_milestone_states from running_exercise.lib.utils import get_running_exercise -from running_exercise.models import ( - ActionLog, - InjectSelection, - EmailThread, - ThreadParticipant, -) +from running_exercise.models import ActionLog, InjectSelection, EmailThread class Query(graphene.ObjectType): @@ -77,7 +81,8 @@ class Query(graphene.ObjectType): team_id=graphene.ID(required=True), visible_only=graphene.Boolean( required=False, - description="Not required, defaults to false. When true only milestones visible to the team are retrieved", + description="""Not required, defaults to true returns only milestones that are visible to the team. + If set to false then all milestones (even not visible to team) are sent""", ), description="Retrieve all milestones for the specific team filtered by team visibility", ) @@ -90,7 +95,8 @@ class Query(graphene.ObjectType): EmailParticipantType, visible_only=graphene.Boolean( required=False, - description="Not required, defaults to false. If set to true then only email contacts that are visible to the team are retrieved", + description="""Not required, defaults to true returns only milestones that are visible to the team. + If set to false then all milestones (even not visible to team) are sent""", ), description="Retrieve all email contacts for the running exercise", ) @@ -171,15 +177,20 @@ class Query(graphene.ObjectType): description="Retrieve all tools for the specific exercise. Useful for analytics.", ) + @protected(Perms.view_trainee_info.full_name) + @team_protected def resolve_team(self, info, team_id: str) -> Team: return get_model(Team, id=int(team_id)) + @protected(Perms.view_trainee_info.full_name) def resolve_team_roles(self, info) -> List[str]: running_exercise = get_running_exercise() if running_exercise is None: raise RunningExerciseOperationException("No exercise is running.") return [team.role for team in running_exercise.teams.all()] + @protected(Perms.view_trainee_info.full_name) + @team_protected def resolve_team_tools(self, info, team_id: str) -> List[Tool]: team = get_model(Team, id=int(team_id)) @@ -189,6 +200,8 @@ class Query(graphene.ObjectType): if has_role(team.role, tool.roles.split(" ")) ] + @protected(Perms.view_extendtool.full_name) + @team_protected def resolve_extended_team_tools(self, info, team_id: str) -> List[Tool]: team = get_model(Team, id=int(team_id)) @@ -198,9 +211,13 @@ class Query(graphene.ObjectType): if has_role(team.role, tool.roles.split(" ")) ] + @protected(Perms.view_trainee_info.full_name) + @log_protected def resolve_action_log(self, info, log_id: str) -> ActionLog: return get_model(ActionLog, id=int(log_id)) + @protected(Perms.view_trainee_info.full_name) + @team_protected def resolve_team_action_logs( self, info, team_id: str ) -> QuerySet[ActionLog]: @@ -211,55 +228,77 @@ class Query(graphene.ObjectType): ) -> QuerySet[ActionLog]: return ActionLog.objects.filter(team_id=team_id, channel_id=channel_id) + @protected(Perms.view_milestone.full_name) + @team_protected + @visibility_protected def resolve_team_milestones( - self, info, team_id: str, visible_only: bool = False + self, info, team_id: str, visible_only: bool = True ) -> List[MilestoneState]: return get_milestone_states(int(team_id), visible_only) + @protected(Perms.view_injectselection.full_name) + @team_protected def resolve_team_inject_selections( self, info, team_id: str ) -> QuerySet[InjectSelection]: return InjectSelection.objects.filter(team_id=team_id) + @protected(Perms.view_email_info.full_name) + @visibility_protected def resolve_email_contacts( - self, info, visible_only: bool = False + self, info, visible_only: bool = True ) -> QuerySet[EmailParticipant]: return EmailClient.get_contacts(visible_only) + @protected(Perms.view_email_info.full_name) def resolve_email_contact( self, info, participant_id: str ) -> EmailParticipant: return get_model(EmailParticipant, id=int(participant_id)) + @protected(Perms.view_email.full_name) + @team_protected def resolve_email_threads( self, info, team_id: str ) -> QuerySet[EmailThread]: return EmailClient.get_threads(int(team_id)) + @protected(Perms.view_email.full_name) + @thread_protected def resolve_email_thread(self, info, thread_id: str) -> EmailThread: return get_model(EmailThread, id=int(thread_id)) + @protected(Perms.view_email_info.full_name) + @thread_protected def resolve_email_addresses(self, info, thread_id: str) -> List[str]: return EmailClient.get_instructor_email_addresses(int(thread_id)) + @protected(Perms.view_email_info.full_name) + @team_protected def resolve_team_email_participant( self, info, team_id: str ) -> EmailParticipant: return EmailClient.get_team_participant(int(team_id)) + @protected(Perms.view_injectselection.full_name) + @exercise_protected def resolve_email_templates( self, info, exercise_id: str, email_addresses: List[str] ) -> List[EmailTemplate]: return EmailClient.get_email_templates(exercise_id, email_addresses) + @protected(Perms.view_injectselection.full_name) + @thread_protected def resolve_thread_templates( self, info, thread_id: int ) -> List[EmailTemplate]: return EmailClient.get_thread_templates(int(thread_id)) + @protected(Perms.view_injectselection.full_name) def resolve_thread_template(self, info, template_id: str) -> EmailTemplate: return get_model(EmailTemplate, id=int(template_id)) + @protected(Perms.view_exercise.full_name) def resolve_exercise_time_left(self, info) -> int: running_exercise = get_running_exercise() if not running_exercise: @@ -271,6 +310,8 @@ class Query(graphene.ObjectType): ) return max(0, exercise_duration_s - int(running_exercise.elapsed_s)) + @protected(Perms.view_exercise.full_name) # TODO: decide if correct? + @exercise_protected def resolve_exercise_config(self, info, exercise_id: str) -> GrapheneConfig: exercise = get_model(Exercise, id=int(exercise_id)) @@ -285,9 +326,13 @@ class Query(graphene.ObjectType): custom_email_suffix=config.custom_email_suffix, ) + @protected(Perms.view_exercise.full_name) + @exercise_protected # TODO: leave exercise_protected? def resolve_exercise_loop_running(self, info, exercise_id: str) -> bool: return ExerciseLoop.is_running(int(exercise_id)) + @protected(Perms.view_analytics.full_name) + @exercise_protected def resolve_analytics_milestones( self, info, exercise_id: str ) -> QuerySet[MilestoneState]: @@ -295,6 +340,8 @@ class Query(graphene.ObjectType): team_state__exercise_id=int(exercise_id) ) + @protected(Perms.view_analytics.full_name) + @exercise_protected def resolve_analytics_action_logs( self, info, exercise_id: str ) -> QuerySet[ActionLog]: @@ -302,18 +349,17 @@ class Query(graphene.ObjectType): return ActionLog.objects.filter(team__in=exercise.teams.all()) + @protected(Perms.view_analytics.full_name) + @exercise_protected def resolve_analytics_email_threads( self, info, exercise_id: str ) -> QuerySet[EmailThread]: - exercise = get_model(Exercise, id=int(exercise_id)) - # TODO: add exercise to EmailThread for simpler filtering - thread_ids = ThreadParticipant.objects.filter( - email_participant__exercise=exercise - ).values_list("email_thread_id", flat=True) - return EmailThread.objects.filter(id__in=thread_ids).prefetch_related( - "emails" - ) + return EmailThread.objects.filter( + exercise_id=exercise_id + ).prefetch_related("emails") + @protected(Perms.view_analytics.full_name) + @exercise_protected def resolve_exercise_tools(self, info, exercise_id: str) -> List[Tool]: exercise = get_model(Exercise, id=int(exercise_id)) diff --git a/running_exercise/serializers.py b/running_exercise/serializers.py index 3143475d..248d7e4c 100644 --- a/running_exercise/serializers.py +++ b/running_exercise/serializers.py @@ -1,6 +1,12 @@ +from typing import Optional + from rest_framework.fields import CharField from rest_framework.relations import RelatedField -from rest_framework.serializers import ModelSerializer, IntegerField +from rest_framework.serializers import ( + ModelSerializer, + IntegerField, + SerializerMethodField, +) from exercise_definition.serializers import ( ContentSerializer, @@ -18,6 +24,7 @@ from running_exercise.models import ( class EmailSerializer(ModelSerializer): email_id = IntegerField(source="id") content = ContentSerializer() + user_analytics_id = SerializerMethodField() class Meta: model = Email @@ -26,8 +33,18 @@ class EmailSerializer(ModelSerializer): "thread_id", "sender_id", "timestamp", + "content", + "user_analytics_id", ] + def get_user_analytics_id(self, instance) -> Optional[int]: + if instance.user is None: + return None + analytics_ids = self.context.get("analytics_ids", None) + if analytics_ids is None: + raise ValueError("Missing analytics_ids mapping in EmailSerializer") + return analytics_ids[instance.user.username] + class EmailThreadSerializer(ModelSerializer): thread_id = IntegerField(source="id") @@ -46,10 +63,19 @@ class EmailThreadSerializer(ModelSerializer): class ToolDetailsSerializer(ModelSerializer): content = ContentSerializer() + user_analytics_id = SerializerMethodField() class Meta: model = ToolDetails - fields = ["tool_id", "argument", "content"] + fields = ["tool_id", "argument", "content", "user_analytics_id"] + + def get_user_analytics_id(self, instance) -> int: + analytics_ids = self.context.get("analytics_ids", None) + if analytics_ids is None: + raise ValueError( + "No analytics_ids provided to ToolDetailsSerializer" + ) + return analytics_ids[instance.user.username] class InjectDetailsSerializer(ModelSerializer): @@ -62,22 +88,29 @@ class InjectDetailsSerializer(ModelSerializer): class CustomInjectDetailsSerializer(ModelSerializer): content = ContentSerializer() + user_analytics_id = SerializerMethodField() class Meta: model = CustomInjectDetails - fields = ["content", "sender"] + fields = ["content", "sender", "user_analytics_id"] + + def get_user_analytics_id(self, instance) -> int: + analytics_ids = self.context.get("analytics_ids", None) + return analytics_ids[instance.user.username] class DetailsRelatedField(RelatedField): def to_representation(self, value): if isinstance(value, ToolDetails): - serializer = ToolDetailsSerializer(value) + serializer = ToolDetailsSerializer(value, context=self.context) elif isinstance(value, InjectDetails): - serializer = InjectDetailsSerializer(value) + serializer = InjectDetailsSerializer(value, context=self.context) elif isinstance(value, CustomInjectDetails): - serializer = CustomInjectDetailsSerializer(value) + serializer = CustomInjectDetailsSerializer( + value, context=self.context + ) elif isinstance(value, Email): - serializer = EmailSerializer(value) + serializer = EmailSerializer(value, context=self.context) else: raise Exception("Unknown details type") diff --git a/running_exercise/subscription.py b/running_exercise/subscription.py index 48c38869..70fd7acf 100644 --- a/running_exercise/subscription.py +++ b/running_exercise/subscription.py @@ -9,6 +9,13 @@ from common_lib.schema_types import ( ) from common_lib.utils import get_model, get_subscription_group from exercise.models import Team, Exercise +from aai.models import Perms +from aai.utils import ( + protected, + exercise_protected, + team_protected, + visibility_protected, +) NOTIFICATION_QUEUE_LIMIT = 64 @@ -22,6 +29,8 @@ class ExerciseLoopRunningSubscription(channels_graphql_ws.Subscription): exercise_id = graphene.ID() @staticmethod + @protected(Perms.view_exercise.full_name) + @exercise_protected def subscribe(root, info, exercise_id: str): get_model(Exercise, id=int(exercise_id)) return [exercise_id] @@ -40,6 +49,8 @@ class ActionLogsSubscription(channels_graphql_ws.Subscription): team_id = graphene.ID() @staticmethod + @protected(Perms.view_trainee_info.full_name) + @team_protected def subscribe(root, info, team_id: str): get_model(Team, id=int(team_id)) return [team_id] @@ -57,16 +68,20 @@ class MilestonesSubscription(channels_graphql_ws.Subscription): team_id = graphene.ID(required=True) visible_only = graphene.Boolean( required=False, - description="Not required, defaults to false. If set to true then only milestones that are visible to the team are sent", + description="""Not required, defaults to true returns only milestones that are visible to the team. + If set to false then all milestones (even not visible to team) are sent""", ) @staticmethod - def subscribe(root, info, team_id: str, visible_only: bool = False): + @protected(Perms.view_milestone.full_name) + @team_protected + @visibility_protected + def subscribe(root, info, team_id: str, visible_only: bool = True): get_model(Team, id=int(team_id)) return ["all", get_subscription_group(int(team_id), visible_only)] @staticmethod - def publish(payload, info, team_id: str, visible_only: bool = False): + def publish(payload, info, team_id: str, visible_only: bool = True): return MilestonesSubscription(milestones=payload) @@ -78,6 +93,8 @@ class InjectSelectionsSubscription(channels_graphql_ws.Subscription): team_id = graphene.ID() @staticmethod + @protected(Perms.send_injectselection.full_name) + @team_protected def subscribe(root, info, team_id: str): get_model(Team, id=int(team_id)) return [team_id] @@ -95,6 +112,8 @@ class EmailThreadSubscription(channels_graphql_ws.Subscription): team_id = graphene.ID() @staticmethod + @protected(Perms.view_email.full_name) + @team_protected def subscribe(root, info, team_id: str): get_model(Team, id=int(team_id)) return [team_id] @@ -112,6 +131,8 @@ class AnalyticsMilestonesSubscription(channels_graphql_ws.Subscription): exercise_id = graphene.ID() @staticmethod + @protected(Perms.view_analytics.full_name) + @exercise_protected def subscribe(root, info, exercise_id: str): get_model(Exercise, id=int(exercise_id)) return [exercise_id] @@ -129,6 +150,8 @@ class AnalyticsActionLogsSubscription(channels_graphql_ws.Subscription): exercise_id = graphene.ID() @staticmethod + @protected(Perms.view_analytics.full_name) + @exercise_protected def subscribe(root, info, exercise_id: str): get_model(Exercise, id=int(exercise_id)) return [exercise_id] @@ -146,6 +169,8 @@ class AnalyticsEmailThreadSubscription(channels_graphql_ws.Subscription): exercise_id = graphene.ID() @staticmethod + @protected(Perms.view_analytics.full_name) + @exercise_protected def subscribe(root, info, exercise_id: str): get_model(Exercise, id=int(exercise_id)) return [exercise_id] diff --git a/running_exercise/tests/email_tests.py b/running_exercise/tests/email_tests.py index 0d925b66..5b20b1de 100644 --- a/running_exercise/tests/email_tests.py +++ b/running_exercise/tests/email_tests.py @@ -124,27 +124,37 @@ class EmailTests(TestCase): ] with self.assertRaises(RunningExerciseOperationException) as _: - EmailClient.create_thread(participants, self.subject) + EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) participants.append("nonexistentaddress@mail.com") with self.assertRaises(ModelNotFoundException) as _: - EmailClient.create_thread(participants, self.subject) + EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) # multiple definition addresses participants[1] = "test@mail.ex" with self.assertRaises(RunningExerciseOperationException) as _: - EmailClient.create_thread(participants, self.subject) + EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) _disable_feature(self.exercise, Feature.EMAIL_BETWEEN_TEAMS) participants[1] = "team-1@mail.com" participants.append("team-2@mail.com") with self.assertRaises(RunningExerciseOperationException) as _: - EmailClient.create_thread(participants, self.subject) + EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) def test_create_thread(self): participants = ["team-1@mail.com", "test@mail.ex"] - thread = EmailClient.create_thread(participants, self.subject) + thread = EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) self._compare_expected_participants( participants, thread.participants.all() ) @@ -155,7 +165,9 @@ class EmailTests(TestCase): # test for email between teams participants.append("team-2@mail.com") - thread = EmailClient.create_thread(participants, self.subject) + thread = EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) self._compare_expected_participants( participants, thread.participants.all() ) @@ -167,7 +179,9 @@ class EmailTests(TestCase): # test for thread only between teams # remove definition address participants = ["team-1@mail.com", "team-2@mail.com"] - thread = EmailClient.create_thread(participants, self.subject) + thread = EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) self._compare_expected_participants( participants, thread.participants.all() ) @@ -189,7 +203,11 @@ class EmailTests(TestCase): threads = [] for participants in participant_combinations: for subject in subjects: - threads.append(EmailClient.create_thread(participants, subject)) + threads.append( + EmailClient.create_thread( + participants, subject, str(self.exercise.id) + ) + ) # 1) get an existing thread participants = participant_combinations[1] @@ -240,7 +258,9 @@ class EmailTests(TestCase): ) participants = ["team-1@mail.com", "team-2@mail.com"] - thread = EmailClient.create_thread(participants, self.subject) + thread = EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) with self.assertRaises(ModelNotFoundException) as _: EmailClient.send_email( @@ -253,7 +273,9 @@ class EmailTests(TestCase): def test_instructor_email(self): participants = ["test@mail.ex", "team-1@mail.com", "team-2@mail.com"] - thread = EmailClient.create_thread(participants, self.subject) + thread = EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) milestone = self.definition.milestones.get(name="evaluation_great") expected_sender = self.participants["test@mail.ex"] for team in self.exercise.teams.all(): @@ -304,7 +326,9 @@ class EmailTests(TestCase): ) participants = ["doe@mail.ex", "team-1@mail.com"] - thread = EmailClient.create_thread(participants, self.subject) + thread = EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) invalid_participant = self.participants["team-2@mail.com"] with self.assertRaises(ModelNotFoundException) as _: @@ -317,7 +341,9 @@ class EmailTests(TestCase): ) participants = ["team-1@mail.com", "team-2@mail.com"] - thread = EmailClient.create_thread(participants, self.subject) + thread = EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) invalid_participant = self.participants["team-3@mail.com"] with self.assertRaises(ModelNotFoundException) as _: EmailClient.send_email( @@ -333,8 +359,10 @@ class EmailTests(TestCase): milestone = self.definition.milestones.get(name="email_doe") team_sender = self.participants["team-1@mail.com"] definition_sender = self.participants["doe@mail.ex"] - thread = EmailClient.create_thread(participants, self.subject) email_channel = self.definition.channels.get(type=InjectTypes.EMAIL) + thread = EmailClient.create_thread( + participants, self.subject, str(self.exercise.id) + ) # check that milestones from the definition address were not activated on thread create self.assertFalse(is_milestone_reached(team_sender.team, milestone)) diff --git a/running_exercise/tests/serializers_tests.py b/running_exercise/tests/serializers_tests.py new file mode 100644 index 00000000..4d5fa20a --- /dev/null +++ b/running_exercise/tests/serializers_tests.py @@ -0,0 +1,132 @@ +from typing import List, Dict +import os +import shutil + +from django.test import TestCase, override_settings + +from django.conf import settings +from exercise.serializers import ( + UserSerializer, + TeamSerializer, + InstructorSerializer, +) +from common_lib.test_utils import ( + internal_create_exercise, + internal_upload_definition, +) +from user.models import User, UserInTeam, InstructorOfExercise +from exercise.models import Exercise, Team + +TEST_DATA_STORAGE = "running_exercise_tests_test_data" + + +@override_settings( + DATA_STORAGE=TEST_DATA_STORAGE, + FILE_STORAGE=os.path.join(TEST_DATA_STORAGE, "files"), +) +class SerializerTest(TestCase): + exercise: Exercise + trainees: List[User] + instructors: List[User] + teams: List[Team] + analytics_ids: Dict[str, object] + + @classmethod + def tearDownClass(cls): + shutil.rmtree(settings.DATA_STORAGE) + super().tearDownClass() + + @classmethod + def setUpTestData(cls): + num_of_teams = 3 + + definition = internal_upload_definition("base_definition") + cls.exercise = internal_create_exercise( + definition_id=definition.id, team_count=num_of_teams + ) + cls.trainees, cls.instructors = [], [] + cls.analytics_ids = {} + cls.teams = cls.exercise.teams.all() + + trainees_count = 6 + for i in range(trainees_count): + user = User.objects.create_user( + username=f"u{i}@mail.com", password=f"u" + ) + cls.trainees.append(user) + UserInTeam(user=user, team=cls.teams[i % num_of_teams]).save() + cls.analytics_ids[user.username] = i + + for i in range(2): + user = User.objects.create_staffuser( + username=f"i{i}@mail.com", password="i" + ) + cls.instructors.append(user) + InstructorOfExercise(user=user, exercise=cls.exercise).save() + cls.analytics_ids[user.username] = i + trainees_count + + def _user_serializer_check(self, users: List[User], anonymize: bool): + context = {"analytics_ids": self.analytics_ids, "anonymize": anonymize} + + for user in users: + data = UserSerializer(user, context=context).data + self.assertEqual( + data["username"], None if anonymize else user.username + ) + self.assertEqual( + data["analytics_id"], self.analytics_ids[user.username] + ) + + def test_user_serializer_anonymized(self): + self._user_serializer_check(self.trainees, True) + self._user_serializer_check(self.instructors, True) + + def test_user_serializer_nonanonymized(self): + self._user_serializer_check(self.trainees, False) + self._user_serializer_check(self.instructors, False) + + def _team_serializer_check(self, anonymize: bool): + context = {"analytics_ids": self.analytics_ids, "anonymize": anonymize} + for team in self.teams: + data = TeamSerializer(team, context=context).data + for i, user in enumerate(team.user_set.all()): + self.assertEqual( + data["users"][i]["username"], + None if anonymize else user.username, + ) + self.assertEqual( + data["users"][i]["analytics_id"], + self.analytics_ids[user.username], + ) + + def test_team_serializer(self): + self._team_serializer_check(True) + self._team_serializer_check(False) + + def _check_instructor_serializer(self, anonymize: True): + context = {"analytics_ids": self.analytics_ids, "anonymize": anonymize} + data = InstructorSerializer(self.exercise, context=context).data + + for i, user in enumerate(self.instructors): + self.assertEqual( + data["instructors"][i]["username"], + None if anonymize else user.username, + ) + self.assertEqual( + data["instructors"][i]["analytics_id"], + self.analytics_ids[user.username], + ) + + def test_instructor_serializer(self): + self._check_instructor_serializer(True) + self._check_instructor_serializer(False) + + def test_team_serializer_invalid(self): + # raises exception due to the missing analytics_ids mapping + with self.assertRaises(ValueError): + UserSerializer(self.trainees[0]).data + + def test_instructor_serializer_invalid(self): + # raises exception due to the missing analytics_ids mapping + with self.assertRaises(ValueError): + InstructorSerializer(self.exercise).data diff --git a/running_exercise/views.py b/running_exercise/views.py index 8f693b44..c44b1130 100644 --- a/running_exercise/views.py +++ b/running_exercise/views.py @@ -15,6 +15,8 @@ from running_exercise.lib.file_handler import ( upload_file, get_uploaded_file, ) +from aai.models import Perms +from aai.utils import protected, team_protected class GetFileView(APIView): @@ -26,6 +28,8 @@ class GetFileView(APIView): 500: ERROR_RESPONSE, }, ) + @protected(Perms.manipulate_file.full_name) + @team_protected def get(self, request, *args, **kwargs): """ Get the requested file. If the exercise is running and parameter 'instructor' is not specified, @@ -66,6 +70,8 @@ class UploadFileView(APIView): 500: ERROR_RESPONSE, }, ) + @protected(Perms.manipulate_file.full_name) + @team_protected def post(self, request, *args, **kwargs): """Upload a file as a team.""" file = request.FILES.get("file") diff --git a/ttxbackend/schema.py b/ttxbackend/schema.py index f44346b5..a23ff69e 100644 --- a/ttxbackend/schema.py +++ b/ttxbackend/schema.py @@ -15,16 +15,20 @@ from running_exercise.schema import ( Query as RunningExerciseQuery, Mutation as RunningExerciseMutation, ) +from aai.schema import Mutation as AaiMutation +from user.schema import Query as UserQuery, Mutation as UserMutation from running_exercise.subscription import ( Subscription as RunningExerciseSubscription, ) -class Query(ExerciseQuery, RunningExerciseQuery): +class Query(ExerciseQuery, RunningExerciseQuery, UserQuery): pass -class Mutation(ExerciseMutation, RunningExerciseMutation): +class Mutation( + ExerciseMutation, RunningExerciseMutation, AaiMutation, UserMutation +): pass diff --git a/ttxbackend/settings.py b/ttxbackend/settings.py index 4d87a66d..64e1b01c 100644 --- a/ttxbackend/settings.py +++ b/ttxbackend/settings.py @@ -53,6 +53,8 @@ INSTALLED_APPS = [ "exercise_definition", "exercise", "running_exercise", + "user", + "aai", "django_nose", ] @@ -186,7 +188,24 @@ with open( ) as def_changelog: DEFINITION_VERSION = def_changelog.readline().lstrip("# ").rstrip() -# TODO: update to use KNOWN_HOST (for use this for allowing frontend sending cookies) + +# Authentication +AUTH_USER_MODEL = "user.User" +SESSION_COOKIE_HTTPONLY = True +CSRF_USE_SESSIONS = True +SESSION_COOKIE_AGE = 24 * 60 * 60 # Session validity period in seconds +AUTHENTICATION_BACKENDS = ["aai.backend.CustomAuthBackend"] + +# Email client +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = os.environ.get("EMAIL_HOST") or "relay.fi.muni.cz" +EMAIL_PORT = 465 +EMAIL_HOST_USER = "" +EMAIL_HOST_PASSWORD = "" +EMAIL_USE_SSL = True +EMAIL_SENDER = os.environ.get("EMAIL_SENDER") or "inject@fi.muni.cz" + +# TODO: update to use modified KNOWN_HOST (needed for allowing frontend sending cookies) CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOW_CREDENTIALS = True diff --git a/ttxbackend/urls.py b/ttxbackend/urls.py index 0ce2a8d3..7554a07f 100644 --- a/ttxbackend/urls.py +++ b/ttxbackend/urls.py @@ -55,6 +55,7 @@ urlpatterns = [ path("", include("exercise_definition.urls")), path("", include("running_exercise.urls")), path("", include("exercise.urls")), + path("", include("user.urls")), ] # Prefixing urls diff --git a/user/__init__.py b/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/user/admin.py b/user/admin.py new file mode 100644 index 00000000..54c0d68a --- /dev/null +++ b/user/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from user.models import User + +admin.site.register(User) diff --git a/user/apps.py b/user/apps.py new file mode 100644 index 00000000..1289c12e --- /dev/null +++ b/user/apps.py @@ -0,0 +1,21 @@ +from django.apps import AppConfig +from json import JSONEncoder +from uuid import UUID + + +class UserConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "user" + + +old_default = JSONEncoder.default + + +# python json encoder can't handle UUID type objects +def new_default(self, obj): + if isinstance(obj, UUID): + return str(obj) + return old_default(self, obj) + + +JSONEncoder.default = new_default # type: ignore diff --git a/user/email/email_sender.py b/user/email/email_sender.py new file mode 100644 index 00000000..26c05015 --- /dev/null +++ b/user/email/email_sender.py @@ -0,0 +1,38 @@ +from typing import List, Tuple + +from django.core.mail import get_connection, EmailMultiAlternatives, send_mail +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +from user.models import User +from ttxbackend.settings import EMAIL_SENDER + + +def send_credentials(new_users: List[Tuple[User, str]]): + subject = "Welcome to INJECT!" + messages = [] + + for user, psswd in new_users: + html_content = render_to_string( + "welcome_email.html", + {"email": user.username, "password": psswd}, + ) + text_content = strip_tags(html_content) + message = EmailMultiAlternatives( + subject, text_content, EMAIL_SENDER, [user.username] + ) + message.attach_alternative(html_content, "text/html") + messages.append(message) + + connection = get_connection(fail_silently=True) + connection.send_messages(messages) + + +def send_password_change_notification(user: User): + # TODO: create html template + send_mail( + subject="INJECT - password change", + message="Your password on the INJECT platform was changed", + from_email=EMAIL_SENDER, + recipient_list=[user.username], + ) diff --git a/user/graphql_inputs.py b/user/graphql_inputs.py new file mode 100644 index 00000000..de0ac9c7 --- /dev/null +++ b/user/graphql_inputs.py @@ -0,0 +1,31 @@ +import graphene + + +class ChangeUserBase: + user_id = graphene.UUID(required=True) + group = graphene.String(required=False, default_value=None) + active = graphene.Boolean(required=False, default_value=None) + + +class ChangeUserType(ChangeUserBase, graphene.ObjectType): + pass + + +class ChangeUserInput(ChangeUserBase, graphene.InputObjectType): + pass + + +class FilterUsersBase: + active = graphene.Boolean(required=False, default_value=None) + group = graphene.List(graphene.String, required=False, default_value=None) + tags = graphene.List(graphene.String, required=False, default_value=None) + limit = graphene.Int(required=False, default_value=None) + skip = graphene.Int(required=False, default_value=None) + + +class FilterUsersType(FilterUsersBase, graphene.ObjectType): + pass + + +class FilterUsersInput(FilterUsersBase, graphene.InputObjectType): + pass diff --git a/user/lib/__init__.py b/user/lib/__init__.py new file mode 100644 index 00000000..1c00b4f8 --- /dev/null +++ b/user/lib/__init__.py @@ -0,0 +1,2 @@ +from .user_uploader import UserUploader +from .user_validator import UserValidator diff --git a/user/lib/user_uploader.py b/user/lib/user_uploader.py new file mode 100644 index 00000000..c61ff4e6 --- /dev/null +++ b/user/lib/user_uploader.py @@ -0,0 +1,90 @@ +import csv +import io +from typing import Tuple, Optional, List, Callable + +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.utils.crypto import get_random_string +from django.contrib.auth.models import Group +from rest_framework.response import Response + +from user.lib.user_validator import UserValidator, ResultAction +from user.models import User, Tag, UserTag +from aai.models import UserGroup +from user.email.email_sender import send_credentials + + +TAG_DELIMITER = "|" + + +def _group_to_create_func(group: UserGroup) -> Callable: + if group == UserGroup.TRAINEE: + return User.objects.create_user + + elif group == UserGroup.INSTRUCTOR: + return User.objects.create_staffuser + + elif group == UserGroup.ADMIN: + return User.objects.create_superuser + + raise ValueError(f"Unexpected UserGroup ({group}) in _group_to_create_func") + + +def _create_and_tag_user( + username: str, group: UserGroup, tags: Optional[str], action: ResultAction +) -> Optional[Tuple[User, str]]: + if action == ResultAction.CREATE_AND_TAG_USER: + password = get_random_string(length=15) + user = _group_to_create_func(group)( + username=username, + password=password, + ) + _tag_user(user, tags) + # password needs to be retained in open form until it's sent to user + return user, password + + if action == ResultAction.TAG_EXISTING_USER: + user = User.objects.get(username=username) + _tag_user(user, tags) + + return None + + +def _tag_user(user: User, tags: Optional[str]): + if tags is None or tags.strip() == "": + return + + tag_db_list = [ + Tag.objects.get_or_create(name=tag.strip()) + for tag in tags.split(TAG_DELIMITER) + ] + UserTag.objects.bulk_create_ignore_conflicts( + UserTag(user=user, tag=tag) for tag in tag_db_list + ) + + +class UserUploader: + @staticmethod + def validate_and_upload( + uploaded_file: InMemoryUploadedFile, admin_initiator: bool + ) -> Response: + file = uploaded_file.read().decode("utf-8") + reader = csv.DictReader( + io.StringIO(file), fieldnames=["username", "group", "tags"] + ) + user_data_list = [ + (row["username"], row["group"], row["tags"]) for row in reader + ] + validator = UserValidator(user_data_list, admin_initiator) + valid_users = validator.validate() + + if not validator.is_success(): + return validator.result_handler.create_response() + + created_users: List[Tuple[User, str]] = [] + for username, group, tags, action in valid_users: + new_user_data = _create_and_tag_user(username, group, tags, action) + if new_user_data is not None: + created_users.append(new_user_data) + + send_credentials(created_users) + return validator.result_handler.create_response() diff --git a/user/lib/user_validator.py b/user/lib/user_validator.py new file mode 100644 index 00000000..1ec8692e --- /dev/null +++ b/user/lib/user_validator.py @@ -0,0 +1,163 @@ +from typing import List, Tuple +import re +from enum import Enum + +from rest_framework.response import Response +from rest_framework import status +from django.contrib.auth.models import Group + +from aai.models import UserGroup +from user.models import User, EMAIL_REGEX + +Username = str +Group_str = str +Tags = str +UserRawData = Tuple[Username, Group_str, Tags] +UserValidatedData = Tuple[Username, UserGroup, Tags, "ResultAction"] + + +class ResultAction(Enum): + CREATE_AND_TAG_USER = 0 + TAG_EXISTING_USER = 1 + SKIP = 2 + ERROR = 3 + + +class UserValidationResultHandler: + def __init__(self): + self.errors = [] + self.warnings = [] + + def add_error(self, error_msg: str): + self.errors.append(error_msg + "\n") + + def add_warning(self, warning_msg: str): + self.warnings.append(warning_msg + "\n") + + def _build_final_message(self) -> str: + msg = "" + if self.errors: + msg += "Errors:\n" + "".join(self.errors) + 20 * "-" + "\n" + msg += "!In order to successfully finish users creation correct all errors!\n" + if self.warnings: + if self.errors: + msg += 20 * "-" # add separator between errors and warnings + msg += "Warnings:\n" + "".join(self.warnings) + "\n" + return msg + + def is_success(self) -> bool: + return len(self.errors) == 0 + + def create_response(self) -> Response: + if self.errors: + return Response( + { + "status": "error", + "detail": "Unsuccessful users creation.\n" + + self._build_final_message(), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + if self.warnings: + return Response( + { + "status": "ok", + "detail": "Users were created.\n" + + self._build_final_message(), + } + ) + return Response( + {"status": "ok", "detail": "All users were successfully created.\n"} + ) + + +class UserValidator: + def __init__(self, new_users: List[UserRawData], admin_initiator: bool): + self.new_users = new_users + self.result_handler = UserValidationResultHandler() + self.admin_initiator = admin_initiator + self.current_usernames = set( + User.objects.all().values_list("username", flat=True) + ) + + def _validate_username( + self, username: str, used_usernames: List[str] + ) -> ResultAction: + result = ResultAction.CREATE_AND_TAG_USER + if username in self.current_usernames: + self.result_handler.add_warning( + f"User with username: {username} already exists. Skipping creation..." + ) + result = ResultAction.TAG_EXISTING_USER + + if username in used_usernames: + self.result_handler.add_warning( + f"User with username: {username} is more than once in a csv." + + "Using only first occurrence for user creation, skipping all other creations..." + ) + result = ResultAction.SKIP + + if not (re.fullmatch(EMAIL_REGEX, username)): + self.result_handler.add_error( + f"User username:{username} has invalid format." + ) + result = ResultAction.ERROR + return result + + def _validate_group(self, username: str, group: str) -> bool: + create = True + if group is None or group == "": + # For convenience of users "Empty" is taken as TRAINEE and thus valid + return True + + if UserGroup.from_str(group) is None: + self.result_handler.add_error( + f"User with username:{username} has definined invalid role: {group}." + ) + create = False + + elif ( + UserGroup.from_str(group) == UserGroup.ADMIN + and not self.admin_initiator + ): + self.result_handler.add_error( + f"Cannot assign admin authorization group to user: {username} " + + f"because you are not admin." + ) + create = False + return create + + def is_success(self) -> bool: + return self.result_handler.is_success() + + def validate(self) -> List[UserValidatedData]: + used_usernames: List[str] = [] + valid_users: List[UserValidatedData] = [] + + for username, group, tags in self.new_users: + group_valid = False + username = username.strip() if username is not None else None + group = group.strip() if group is not None else None + + result_action = self._validate_username(username, used_usernames) + + if self._validate_group(username, group): + group_valid = True + + if group_valid and result_action not in ( + ResultAction.SKIP, + ResultAction.ERROR, + ): + typed_group = ( + UserGroup.from_str(group) + if group is not None and group != "" + else UserGroup.TRAINEE + ) + assert ( + typed_group is not None + ) # validation was success -> can't be None + valid_users.append((username, typed_group, tags, result_action)) + + used_usernames.append(username) + + return valid_users diff --git a/user/migrations/0001_initial.py b/user/migrations/0001_initial.py new file mode 100644 index 00000000..21f7664d --- /dev/null +++ b/user/migrations/0001_initial.py @@ -0,0 +1,117 @@ +# Generated by Django 3.2.24 on 2024-03-01 11:17 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import user.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('exercise', '0009_auto_20240227_1813'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + 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')), + ('email', models.EmailField(max_length=254, unique=True)), + ('username', models.CharField(blank=True, max_length=150, null=True, unique=True)), + ('first_name', models.CharField(blank=True, max_length=150, null=True)), + ('last_name', models.CharField(blank=True, max_length=150, null=True)), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now)), + ('is_staff', models.BooleanField(default=False)), + ('is_superuser', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'default_permissions': (), + }, + managers=[ + ('objects', user.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=20)), + ], + options={ + 'default_permissions': (), + }, + ), + migrations.CreateModel( + name='UserTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='user.tag')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'default_permissions': (), + }, + ), + migrations.CreateModel( + name='UserInTeam', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='exercise.team')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'default_permissions': (), + }, + ), + migrations.CreateModel( + name='InstructorOfExercise', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='exercise.exercise')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'default_permissions': (), + }, + ), + migrations.AddField( + model_name='user', + name='exercises', + field=models.ManyToManyField(through='user.InstructorOfExercise', to='exercise.Exercise'), + ), + migrations.AddField( + model_name='user', + name='group', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='auth.group'), + ), + migrations.AddField( + model_name='user', + name='tags', + field=models.ManyToManyField(through='user.UserTag', to='user.Tag'), + ), + migrations.AddField( + model_name='user', + name='teams', + field=models.ManyToManyField(through='user.UserInTeam', to='exercise.Team'), + ), + migrations.AddConstraint( + model_name='usertag', + constraint=models.UniqueConstraint(fields=('user', 'tag'), name='unique_user_tag'), + ), + migrations.AddConstraint( + model_name='userinteam', + constraint=models.UniqueConstraint(fields=('user', 'team'), name='unique_user_team'), + ), + migrations.AddConstraint( + model_name='instructorofexercise', + constraint=models.UniqueConstraint(fields=('user', 'exercise'), name='unique_user_exercise'), + ), + ] diff --git a/user/migrations/0002_auto_20240320_1646.py b/user/migrations/0002_auto_20240320_1646.py new file mode 100644 index 00000000..39e56e7d --- /dev/null +++ b/user/migrations/0002_auto_20240320_1646.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.24 on 2024-03-20 16:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('exercise', '0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='instructorofexercise', + name='exercise', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='instructors', to='exercise.exercise'), + ), + migrations.AlterField( + model_name='instructorofexercise', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assigned_exercises', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='userinteam', + name='team', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='users', to='exercise.team'), + ), + migrations.AlterField( + model_name='userinteam', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assigned_teams', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/user/migrations/0003_alter_user_id.py b/user/migrations/0003_alter_user_id.py new file mode 100644 index 00000000..90ec0ed3 --- /dev/null +++ b/user/migrations/0003_alter_user_id.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.24 on 2024-03-22 15:05 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0002_auto_20240320_1646'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='id', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True), + ), + ] diff --git a/user/migrations/0004_auto_20240402_1408.py b/user/migrations/0004_auto_20240402_1408.py new file mode 100644 index 00000000..8cfe058c --- /dev/null +++ b/user/migrations/0004_auto_20240402_1408.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.24 on 2024-04-02 14:08 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('exercise_definition', '0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222'), + ('user', '0003_alter_user_id'), + ] + + operations = [ + migrations.CreateModel( + name='DefinitionAccess', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('definition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='maintainers', to='exercise_definition.definition')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='definitions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'default_permissions': (), + }, + ), + migrations.AddConstraint( + model_name='definitionaccess', + constraint=models.UniqueConstraint(fields=('user', 'definition'), name='unique_user_definition'), + ), + ] diff --git a/user/migrations/0005_auto_20240402_1922.py b/user/migrations/0005_auto_20240402_1922.py new file mode 100644 index 00000000..a117db47 --- /dev/null +++ b/user/migrations/0005_auto_20240402_1922.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.24 on 2024-04-02 19:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('exercise_definition', '0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222'), + ('user', '0004_auto_20240402_1408'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='definitions', + field=models.ManyToManyField(through='user.DefinitionAccess', to='exercise_definition.Definition'), + ), + migrations.AlterField( + model_name='definitionaccess', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/user/migrations/0006_auto_20240405_1401.py b/user/migrations/0006_auto_20240405_1401.py new file mode 100644 index 00000000..7acf5a97 --- /dev/null +++ b/user/migrations/0006_auto_20240405_1401.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.24 on 2024-04-05 14:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0005_auto_20240402_1922'), + ] + + operations = [ + migrations.AlterField( + model_name='instructorofexercise', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='userinteam', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/user/migrations/0007_auto_20240405_1518.py b/user/migrations/0007_auto_20240405_1518.py new file mode 100644 index 00000000..59b551f5 --- /dev/null +++ b/user/migrations/0007_auto_20240405_1518.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.24 on 2024-04-05 15:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0006_auto_20240405_1401'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='email', + ), + migrations.AlterField( + model_name='user', + name='username', + field=models.CharField(max_length=150, unique=True), + ), + ] diff --git a/user/migrations/__init__.py b/user/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/user/models.py b/user/models.py new file mode 100644 index 00000000..5a57fa85 --- /dev/null +++ b/user/models.py @@ -0,0 +1,238 @@ +import re +import uuid + +from django.db import models, transaction +from django.apps import apps +from django.utils import timezone + +from django.contrib.auth.models import BaseUserManager, AbstractBaseUser, Group +from django.contrib.auth.hashers import make_password +from django.db.models.signals import post_save, m2m_changed +from django.dispatch import receiver + +from aai.models import UserGroup +from exercise.models import Team, Exercise, Definition + +EMAIL_REGEX = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b" +PERM_CACHE = "_perm_cache" + + +class UserManager(BaseUserManager): + use_in_migrations = True + + def _create_user(self, username, password, group=None, **extra_fields): + """ + Create and save a user with the given username and password. + """ + if not username: + raise ValueError("The given username must be set") + if not re.fullmatch(EMAIL_REGEX, username): + raise ValueError( + f"The given username: {username} has incorrect format" + ) + + username = self.normalize_email(username) + user = self.model(username=username, **extra_fields) + user.password = make_password(password) + user.save( + using=self._db + ) # user must have assigned id when adding group + + if group is not None: + group, _ = Group.objects.get_or_create(name=group) + user.group = group + user.save() + + return user + + def create_superuser(self, username, password, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + return self._create_user( + username, password, UserGroup.ADMIN, **extra_fields + ) + + def create_staffuser(self, username, password, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", False) + return self._create_user( + username, password, UserGroup.INSTRUCTOR, **extra_fields + ) + + def create_user(self, username, password, **extra_fields): + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user( + username, password, UserGroup.TRAINEE, **extra_fields + ) + + +class TagManager(models.Manager): + def get_or_create(self, name): + existing = self.get_queryset().filter(name=name).first() + + if existing: + return existing + return self.create(name=name) + + +class UserTagManager(models.Manager): + def get_or_create_ignore_duplicates(self, **kwargs): + user_id = kwargs.pop("user_id", None) + tag_id = kwargs.pop("tag_id", None) + if user_id is not None and tag_id is not None: + existing_pair = UserTag.objects.filter( + user_id=user_id, tag_id=tag_id + ).first() + if existing_pair: + return existing_pair + + # Create a new UserTag instance + with transaction.atomic(): + user_tag = UserTag(**kwargs) + user_tag.save() + return user_tag + + return None + + def bulk_create_ignore_conflicts(self, objs, batch_size=None): + return self.bulk_create( + objs, batch_size=batch_size, ignore_conflicts=True + ) + + +class User(AbstractBaseUser): + id = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + unique=True, + editable=False, + ) + username = models.CharField(max_length=150, unique=True, blank=False) + first_name = models.CharField(max_length=150, blank=True, null=True) + last_name = models.CharField(max_length=150, blank=True, null=True) + date_joined = models.DateTimeField(default=timezone.now) + group = models.ForeignKey(Group, on_delete=models.SET_NULL, null=True) + is_staff = models.BooleanField(default=False) + is_superuser = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + teams = models.ManyToManyField(Team, through="UserInTeam") + exercises = models.ManyToManyField(Exercise, through="InstructorOfExercise") + definitions = models.ManyToManyField(Definition, through="DefinitionAccess") + tags = models.ManyToManyField("Tag", through="UserTag") + + objects = UserManager() + + USERNAME_FIELD = "username" + + class Meta: + default_permissions = () + + def get_username(self): + return getattr(self, self.USERNAME_FIELD) + + def __str__(self): + return self.get_username() + + def has_perm(self, perm): + if self.is_superuser: + return True + + if not self.is_active or self.is_anonymous or self.group is None: + return False + + if not hasattr(self, PERM_CACHE): + perms = self.group.permissions.all().values_list( + "content_type__app_label", "codename" + ) + setattr( + self, + PERM_CACHE, + {f"{ct}.{name}" for ct, name in perms}, + ) + return perm in getattr(self, PERM_CACHE) + + @classmethod + def invalidate_cache(cls): + for user in cls.objects.all(): + if hasattr(user, PERM_CACHE): + delattr(user, PERM_CACHE) + + +@receiver(post_save, sender=Group) +@receiver(m2m_changed, sender=Group.permissions.through) +def _user_perm_cache_invalidator(signal, instance, **kwargs): + User.invalidate_cache() + + +class Tag(models.Model): + name = models.CharField(max_length=20, blank=False, null=False) + + objects = TagManager() + + class Meta: + default_permissions = () + + +class UserInTeam(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+") + team = models.ForeignKey( + Team, on_delete=models.CASCADE, related_name="users" + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + name="unique_user_team", + fields=["user", "team"], + ) + ] + default_permissions = () + + +class InstructorOfExercise(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+") + exercise = models.ForeignKey( + Exercise, on_delete=models.CASCADE, related_name="instructors" + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + name="unique_user_exercise", + fields=["user", "exercise"], + ) + ] + default_permissions = () + + +class DefinitionAccess(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+") + definition = models.ForeignKey( + Definition, on_delete=models.CASCADE, related_name="maintainers" + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + name="unique_user_definition", + fields=["user", "definition"], + ) + ] + default_permissions = () + + +class UserTag(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+") + tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name="+") + + class Meta: + constraints = [ + models.UniqueConstraint( + name="unique_user_tag", + fields=["user", "tag"], + ) + ] + default_permissions = () + + objects = UserTagManager() diff --git a/user/schema/__init__.py b/user/schema/__init__.py new file mode 100644 index 00000000..78d2dbab --- /dev/null +++ b/user/schema/__init__.py @@ -0,0 +1,2 @@ +from user.schema.mutation import Mutation +from user.schema.query import Query diff --git a/user/schema/mutation.py b/user/schema/mutation.py new file mode 100644 index 00000000..c75ebec3 --- /dev/null +++ b/user/schema/mutation.py @@ -0,0 +1,237 @@ +import graphene +from typing import List + +from django.contrib.auth.models import Group + +from user.models import UserInTeam, InstructorOfExercise, DefinitionAccess, User +from exercise.models import Team, Exercise, Definition +from user.schema.validators import ( + validate_team_assigning, + validate_team_removing, + validate_instructor_assigning, + validate_instructor_removing, + validate_definition_access_assinging, + validate_definition_access_removing, +) +from common_lib.utils import get_model +from common_lib.schema_types import UserType +from aai.models import Perms +from aai.utils import ( + protected, + team_protected, + exercise_protected, + definition_protected, +) +from user.graphql_inputs import ChangeUserInput + + +class AssignUsersToTeamMutation(graphene.Mutation): + class Arguments: + user_ids = graphene.List( + graphene.ID, + required=True, + description="IDs of the users to be assigned to team", + ) + team_id = graphene.ID( + required=True, + description="ID of the team to which users are being assigned", + ) + + operation_done = graphene.Boolean() + + @classmethod + @protected(Perms.update_userassignment.full_name) + @team_protected + def mutate( + cls, root, info, user_ids: List[str], team_id: str + ) -> graphene.Mutation: + team = get_model(Team, id=team_id) + users = validate_team_assigning(user_ids, team) + UserInTeam.objects.bulk_create( + UserInTeam(user=user, team=team) for user in users + ) + return AssignUsersToTeamMutation(operation_done=True) + + +class RemoveUsersFromTeamMutation(graphene.Mutation): + class Arguments: + user_ids = graphene.List( + graphene.ID, + required=True, + description="IDs of the users to be removed from a team", + ) + team_id = graphene.ID( + required=True, + description="ID of the team from which users are being removed", + ) + + operation_done = graphene.Boolean() + + @classmethod + @protected(Perms.update_userassignment.full_name) + @team_protected + def mutate( + cls, root, info, user_ids: List[str], team_id: str + ) -> graphene.Mutation: + team = get_model(Team, id=team_id) + users = validate_team_removing(user_ids, team) + UserInTeam.objects.filter(user__in=users, team=team).delete() + return RemoveUsersFromTeamMutation(operation_done=True) + + +class AssignInstructorsToExercise(graphene.Mutation): + class Arguments: + user_ids = graphene.List( + graphene.ID, + required=True, + description="IDs of the instructors (users) to be assigned to an exercise", + ) + exercise_id = graphene.ID( + required=True, + description="ID of the exercise to which instructors (users) are being assigned", + ) + + operation_done = graphene.Boolean() + + @classmethod + @protected(Perms.update_userassignment.full_name) + @exercise_protected + def mutate( + cls, root, info, user_ids: List[str], exercise_id: str + ) -> graphene.Mutation: + exercise = get_model(Exercise, id=exercise_id) + users = validate_instructor_assigning(user_ids, exercise) + InstructorOfExercise.objects.bulk_create( + InstructorOfExercise(user=user, exercise=exercise) for user in users + ) + return AssignInstructorsToExercise(operation_done=True) + + +class RemoveInstructorsFromExerciseMutation(graphene.Mutation): + class Arguments: + user_ids = graphene.List( + graphene.ID, + required=True, + description="IDs of the users to be removed from a team", + ) + exercise_id = graphene.ID( + required=True, + description="ID of the exercise from which instructors (users) are being removed", + ) + + operation_done = graphene.Boolean() + + @classmethod + @protected(Perms.update_userassignment.full_name) + @exercise_protected + def mutate( + cls, root, info, user_ids: List[str], exercise_id: str + ) -> graphene.Mutation: + exercise = get_model(Exercise, id=exercise_id) + users = validate_instructor_removing(user_ids, exercise) + InstructorOfExercise.objects.filter( + user__in=users, exercise=exercise + ).delete() + return RemoveUsersFromTeamMutation(operation_done=True) + + +class AddDefinitionAccessMutation(graphene.Mutation): + class Arguments: + user_ids = graphene.List( + graphene.ID, + required=True, + description="IDs of the instructors (users) to be granted access to definition", + ) + definition_id = graphene.ID( + required=True, + description="ID of the definition to which instructors (users) are granted access", + ) + + operation_done = graphene.Boolean() + + @classmethod + @protected(Perms.update_definition.full_name) + @definition_protected + def mutate( + cls, root, info, user_ids: List[str], definition_id: str + ) -> graphene.Mutation: + definition = get_model(Definition, id=int(definition_id)) + users = validate_definition_access_assinging(user_ids) + DefinitionAccess.objects.bulk_create( + DefinitionAccess(user=user, definition=definition) for user in users + ) + return AssignInstructorsToExercise(operation_done=True) + + +class RemoveDefinitionAccessMutation(graphene.Mutation): + class Arguments: + user_ids = graphene.List( + graphene.ID, + required=True, + description="IDs of the users to be removed from a definition access", + ) + definition_id = graphene.ID( + required=True, + description="ID of the definition from which instructors (users) are being removed", + ) + + operation_done = graphene.Boolean() + + @classmethod + @protected(Perms.update_definition.full_name) + @definition_protected + def mutate( + cls, root, info, user_ids: List[str], definition_id: str + ) -> graphene.Mutation: + definition = get_model(Definition, id=int(definition_id)) + users = validate_definition_access_removing(user_ids, definition) + DefinitionAccess.objects.filter( + user__in=users, definition=definition + ).delete() + return RemoveDefinitionAccessMutation(operation_done=True) + + +class ChangeUserDataMutation(graphene.Mutation): + user = graphene.Field(UserType) + + class Arguments: + change_user_input = graphene.Argument(ChangeUserInput, required=True) + + @classmethod + @protected(Perms.update_user) + def mutate( + cls, root, info, change_user_input: ChangeUserInput + ) -> graphene.Mutation: + user = get_model(User, id=change_user_input.user_id) + if change_user_input.group is not None: + user.group = Group.objects.get(name=change_user_input.group) + + if change_user_input.active is not None: + user.is_active = change_user_input.active + + user.save() + return ChangeUserDataMutation(user=user) + + +class Mutation(graphene.ObjectType): + assign_users_to_team = AssignUsersToTeamMutation.Field( + description="Mutation for assigning users to the specific team of the exercise" + ) + remove_users_from_team = RemoveUsersFromTeamMutation.Field( + description="Mutation for removing users from the specific team of the exercise" + ) + assign_instructors_to_exercise = AssignInstructorsToExercise.Field( + description="Mutation for assigning instructors (users) to the exercise" + ) + remove_instructors_from_exercise = RemoveInstructorsFromExerciseMutation.Field( + description="Mutation for removing instructors (users) from the exercise" + ) + add_definition_access = AddDefinitionAccessMutation.Field( + description="Mutation for granting access to the specific definition" + ) + remove_definition_access = RemoveDefinitionAccessMutation.Field( + description="Mutation for removing access of instructors (users) to the definition" + ) + change_user_data = ChangeUserDataMutation.Field( + description="Mutation for changing user data" + ) diff --git a/user/schema/query.py b/user/schema/query.py new file mode 100644 index 00000000..9a8b70ec --- /dev/null +++ b/user/schema/query.py @@ -0,0 +1,73 @@ +from typing import Optional, List + +import graphene +from django.contrib.auth.models import Group + +from common_lib.schema_types import UserType, TagType, GroupType +from common_lib.utils import get_model +from user.models import User, Tag +from aai.models import Perms +from aai.utils import protected +from user.graphql_inputs import FilterUsersInput + + +class Query(graphene.ObjectType): + who_am_i = graphene.Field( + UserType, + description="Retrieve data of the currently logged-in user of the request", + ) + users = graphene.List( + UserType, + filter_users_input=FilterUsersInput(required=False), + description="Retrieve all users with filtering options", + ) + user = graphene.Field(UserType, user_id=graphene.UUID()) + tags = graphene.List( + TagType, description="Retrieve all tags (for filtering)" + ) + groups = graphene.List( + GroupType, description="Retrieve all groups (for filtering)" + ) + + def resolve_who_am_i(self, info) -> Optional[User]: + if info.context.user.is_anonymous: + return None + return info.context.user + + @protected(Perms.view_user.full_name) + def resolve_users( + self, info, filter_users_input: Optional[FilterUsersInput] = None + ) -> List[User]: + if filter_users_input is None: + return User.objects.all() + + users = User.objects.all() + + if filter_users_input.active is not None: + users = users.filter(active=filter_users_input.active) + + if filter_users_input.group is not None: + users = users.filter(group__name=filter_users_input.group) + + if filter_users_input.tags is not None: + for tag in filter_users_input.tags: + users = users.filter(tags__name=tag) + + if filter_users_input.skip is not None: + users = users[filter_users_input.skip :] + + if filter_users_input.limit is not None: + users = users[: filter_users_input.limit] + return users + + @protected(Perms.view_user.full_name) + def resolve_user(self, info, user_id: str) -> User: + return get_model(User, id=user_id) + + @protected(Perms.view_user) + def resolve_tags(self, info) -> List[Tag]: + return Tag.objects.all() + + # TODO: for now leave open to anyone + def resolve_groups(self, info) -> List[Group]: + return Group.objects.all() diff --git a/user/schema/validators.py b/user/schema/validators.py new file mode 100644 index 00000000..69d75ad4 --- /dev/null +++ b/user/schema/validators.py @@ -0,0 +1,149 @@ +from typing import List, Type, TypeVar, Union + +from django.db.models import Model + +from user.models import UserInTeam, User, InstructorOfExercise, DefinitionAccess +from exercise.models import Team, Exercise, Definition +from aai.models import UserGroup + +ModelType = TypeVar("ModelType", bound=Model) + + +def _check_nonexistent_users(user_ids: List[str]) -> List[User]: + present_users = User.objects.filter(id__in=user_ids) + if len(present_users) == len(user_ids): + return present_users + + invalid_ids = [ + user_id + for user_id in user_ids + if user_id not in present_users.values_list("id", flat=True) + ] + raise ValueError(f"Users with ids:{invalid_ids} don't exist") + + +def _check_group(users: List[User], required_groups: List[UserGroup]): + invalid_group_users = [ + user + for user in users + if user.group is None + or not set([user.group.name]).intersection(set(required_groups)) + ] + if not invalid_group_users: + return + + msg_format_users = __create_users_msg_format(invalid_group_users) + raise ValueError( + f"Users {msg_format_users} don't have one of the groups:{required_groups}. " + f"If you want to assign them, set them one of the metioned group." + ) + + +def _check_presence( + model_type: Type[ModelType], + users: List[User], + assigned_to: Union[Exercise, Team, Definition], + **kwargs, +): + assigned_users = model_type.objects.filter(**kwargs) + if len(assigned_users) != len(users): + invalid_users = [user for user in users if user not in assigned_users] + msg_format_users = __create_users_msg_format(invalid_users) + raise ValueError( + f"Users {msg_format_users} are not assigned to {assigned_to} " + f"thus can't be deleted" + ) + + +def __create_users_msg_format(users: List[User]) -> str: + invalid_group_users = [ + ("(" + user.username + ", id: " + str(user.id) + ")") for user in users + ] + return ", ".join(invalid_group_users) + + +def validate_team_assigning(user_ids: List[str], team: Team) -> List[User]: + users = _check_nonexistent_users(user_ids) + + # check whether users are already in a team belonging to the same exercise + teams_of_exercise = team.exercise.teams.all() + assigned_to_team = UserInTeam.objects.filter( + user__in=users, team__in=teams_of_exercise + ) + if assigned_to_team.exists(): + assigned_users = list( + assigned_to_team.values_list("user__username", flat=True) + ) + raise ValueError( + f"Users {assigned_users} already assigned " + f"to a team of the same exercise" + ) + + _check_group(users, [UserGroup.TRAINEE]) + return users + + +def validate_team_removing(user_ids: List[str], team: Team) -> List[User]: + users = _check_nonexistent_users(user_ids) + _check_presence( + UserInTeam, + users, + team, + user_id__in=user_ids, + team_id=team.id, + ) + return users + + +def validate_instructor_assigning( + user_ids: List[str], exercise: Exercise +) -> List[User]: + users = _check_nonexistent_users(user_ids) + + # check for users already assigned to the given exercise + already_assigned = InstructorOfExercise.objects.filter( + user__in=users, exercise=exercise + ) + if already_assigned.exists(): + invalid_users = [invalid.user for invalid in already_assigned] + msg_format_users = __create_users_msg_format(invalid_users) + raise ValueError( + f"Users {msg_format_users} were already assigned to exercise (id:{exercise.id})" + ) + + _check_group(users, [UserGroup.INSTRUCTOR, UserGroup.ADMIN]) + return users + + +def validate_instructor_removing( + user_ids: List[str], exercise: Exercise +) -> List[User]: + users = _check_nonexistent_users(user_ids) + _check_presence( + InstructorOfExercise, + users, + exercise, + user_id__in=user_ids, + exercise_id=exercise.id, + ) + return users + + +def validate_definition_access_assinging(user_ids: List[str]) -> List[User]: + users = _check_nonexistent_users(user_ids) + _check_group(users, [UserGroup.INSTRUCTOR, UserGroup.ADMIN]) + return users + + +def validate_definition_access_removing( + user_ids: List[str], definition: Definition +): + users = _check_nonexistent_users(user_ids) + _check_presence( + DefinitionAccess, + users, + definition, + user_id__in=user_ids, + definition_id=definition.id, + ) + return users diff --git a/user/templates/welcome_email.html b/user/templates/welcome_email.html new file mode 100644 index 00000000..ad87c340 --- /dev/null +++ b/user/templates/welcome_email.html @@ -0,0 +1,8 @@ +<html> +<body> +Hello! +Your account was activated on INJECT.<br> +Your <strong>login credentials</strong> are:<br> +email: {{email}}<br> +password: {{password}}<br> +</body> \ No newline at end of file diff --git a/user/tests/__init__.py b/user/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/user/tests/csv_onboarding_tests.py b/user/tests/csv_onboarding_tests.py new file mode 100644 index 00000000..008659c4 --- /dev/null +++ b/user/tests/csv_onboarding_tests.py @@ -0,0 +1,208 @@ +import io +from typing import List + +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.test import TestCase +from rest_framework.response import Response + +from user.lib.user_uploader import UserUploader, TAG_DELIMITER +from aai.models import UserGroup +from user.models import User, Tag + + +class UserTests(TestCase): + def create_file(self, data: List[str]) -> InMemoryUploadedFile: + data = "\n".join(data) + data = data.encode("utf-8") + byte_io = io.BytesIO(data) + uploaded_file = InMemoryUploadedFile( + file=byte_io, + field_name="test_data", + name="test_data.csv", + content_type="text/csv", + size=None, + charset=None, + ) + return uploaded_file + + def test_valid_data(self): + data = [ + "a@example.com,TRAINEE,", + "b@example.com, INSTRUCTOR", + "c@uni.mail.com,ADMIN ", + "d@uni.mail.com, trainee,", + "e@mail.com,instructor", + "f@mail.com,admin,", + "g@mail.com, t ", + "h@mail.com,i ", + "i@mail.com,a,", + "j@mail.com,TraIneE ", + "k@uni.mail.sk,", + ] + uploaded_file = self.create_file(data) + response: Response = UserUploader.validate_and_upload( + uploaded_file, UserGroup.ADMIN + ) + self.assertEqual( + response.data["status"], "ok", msg=response.data["detail"] + ) + + for line in data: # check all users are created + username = line.split(",")[0].strip() + user = User.objects.get(username=username) + self.assertIsNotNone(user) + + def test_user_exists_warning(self): + data = [ + "a@example.com,TRAINEE", + "b@example.com, INSTRUCTOR", + "c@uni.mail.com,admin", + "d@uni.mail.com,Trainee", + ] + uploaded_file = self.create_file(data) + + User.objects.create_user(username="a@example.com", password="a") + User.objects.create_user(username="b@uni.mail.com", password="b") + + response: Response = UserUploader.validate_and_upload( + uploaded_file, UserGroup.ADMIN + ) + self.assertEqual(response.data["status"], "ok") + self.assertIn( + "Warning", response.data["detail"], msg=response.data["detail"] + ) + + def test_repeated_user_in_csv_warning(self): + data = [ + "a@example.com,TRAINEE", + "b@example.com, INSTRUCTOR", + "c@mail.com,admin", + "a@example.com,Trainee", + ] + uploaded_file = self.create_file(data) + response: Response = UserUploader.validate_and_upload( + uploaded_file, UserGroup.ADMIN + ) + self.assertEqual(response.data["status"], "ok") + self.assertIn( + "Warning", response.data["detail"], msg=response.data["detail"] + ) + + def test_invalid_username_error(self): + data = [ + "aexample.com,TRAINEE", + "b@example.com, INSTRUCTOR", + "cmail.com,admin", + "a@example.com,Trainee", + ] + uploaded_file = self.create_file(data) + response: Response = UserUploader.validate_and_upload( + uploaded_file, UserGroup.ADMIN + ) + self.assertEqual(response.data["status"], "error") + self.assertIn( + "Error", response.data["detail"], msg=response.data["detail"] + ) + + data = [ + "a@,TRAINEE", + "b@example.com, INSTRUCTOR", + "cmail.com,admin", + "a@example.com,Trainee", + ] + uploaded_file = self.create_file(data) + response: Response = UserUploader.validate_and_upload( + uploaded_file, UserGroup.ADMIN + ) + self.assertEqual(response.data["status"], "error") + self.assertIn( + "Error", response.data["detail"], msg=response.data["detail"] + ) + + def test_invalid_group_error(self): + data = [ + "a@example.com,tra", + "b@example.com, ins", + "c@mail.com,ad", + ] + uploaded_file = self.create_file(data) + response: Response = UserUploader.validate_and_upload( + uploaded_file, UserGroup.ADMIN + ) + self.assertEqual(response.data["status"], "error") + self.assertIn( + "Error", response.data["detail"], msg=response.data["detail"] + ) + + def test_invalid_group_hierarchy_error(self): + data = [ + "c@mail.com,ADMIN", + ] + uploaded_file = self.create_file(data) + response: Response = UserUploader.validate_and_upload( + uploaded_file, False + ) + self.assertEqual(response.data["status"], "error") + self.assertIn( + "Error", response.data["detail"], msg=response.data["detail"] + ) + + def test_create_users_with_tag(self): + existing_user = User.objects.create_user( + username="userZ@mail.com", password="p" + ) + + data = [ + "userX@mail.com,trainee,CYBER-EX", + "userY@mail.com, instructor,CYBER-EX", + "userZ@mail.com,trainee,CYBER-EX", + ] + uploaded_file = self.create_file(data) + response: Response = UserUploader.validate_and_upload( + uploaded_file, UserGroup.INSTRUCTOR + ) + + self.assertEqual( + response.data["status"], "ok", msg=response.data["detail"] + ) + tag = Tag.objects.filter(name="CYBER-EX").first() + self.assertIsNotNone(tag) + + for line in data: # check all users are created and have the tag + username = line.split(",")[0].strip() + user = User.objects.get(username=username) + self.assertIsNotNone(user) + self.assertTrue(tag in user.tags.all()) + + # check if already existing user got the Tag as well + self.assertTrue(tag in existing_user.tags.all()) + + def test_create_users_with_several_tags(self): + tags1 = TAG_DELIMITER.join(["INJECT", "team1", "morning"]) + tags2 = TAG_DELIMITER.join(["INJECT", "team1", "afternoon"]) + tags3 = TAG_DELIMITER.join(["INJECT", "team2", "morning"]) + data = [ + "userA@mail.com,trainee," + tags1, + "userB@mail.com, instructor," + tags2, + "userC@mail.com,trainee," + tags3, + ] + + uploaded_file = self.create_file(data) + response: Response = UserUploader.validate_and_upload( + uploaded_file, UserGroup.INSTRUCTOR + ) + self.assertEqual( + response.data["status"], "ok", msg=response.data["detail"] + ) + + for line in data: # check all users were created + username = line.split(",")[0].strip() + tags = line.split(",")[2] + user = User.objects.get(username=username) + self.assertIsNotNone(user) + + for tag in tags.split( + TAG_DELIMITER + ): # check all tags were created and assigned + tag_db = Tag.objects.get(name=tag) + self.assertTrue(tag_db in user.tags.all()) diff --git a/user/tests/graphql_api_tests.py b/user/tests/graphql_api_tests.py new file mode 100644 index 00000000..acbce55f --- /dev/null +++ b/user/tests/graphql_api_tests.py @@ -0,0 +1,289 @@ +import os +import shutil +import uuid + +from django.test import override_settings + +from django.conf import settings +from common_lib.graphql import ( + GraphQLApiTestCase, + AssignUsersToTeamAction, + RemoveUsersFromTeamAction, + AddDefinitionAccessAction, + RemoveDefinitionAccessAction, +) +from common_lib.test_utils import ( + internal_create_exercise, + internal_upload_definition, +) +from user.models import User, UserInTeam, InstructorOfExercise, DefinitionAccess +from exercise.models import Exercise, Team, Definition + + +TEST_DATA_STORAGE = "user_tests_test_data" + + +@override_settings( + DATA_STORAGE=TEST_DATA_STORAGE, + FILE_STORAGE=os.path.join(TEST_DATA_STORAGE, "files"), +) +class UserGraphQLApiTests(GraphQLApiTestCase): + exercise: Exercise + definition: Definition + + @classmethod + def tearDownClass(cls): + shutil.rmtree(settings.DATA_STORAGE) + super().tearDownClass() + + @classmethod + def setUpTestData(cls): + UserInTeam.objects.all().delete() + + Team.objects.all().delete() + cls.definition = internal_upload_definition("base_definition") + cls.exercise = internal_create_exercise(cls.definition.id, 3) + cls.user1 = User.objects.create_user( + username="user1@mail.com", password="user1" + ) + cls.user2 = User.objects.create_user( + username="user2@mail.com", password="user2" + ) + cls.user3 = User.objects.create_user( + username="user3@mail.com", password="user3" + ) + cls.instructor = User.objects.create_staffuser( + username="i@mail.com", password="i" + ) + InstructorOfExercise.objects.create( + user=cls.instructor, exercise=cls.exercise + ).save() + DefinitionAccess.objects.create( + user=cls.instructor, definition=cls.definition + ) + cls.instructor2 = User.objects.create_staffuser( + username="i2@mail.com", password="i2" + ) + cls.admin = User.objects.create_superuser( + username="admin@mail.com", password="admin" + ) + cls.teams = cls.exercise.teams.all() + cls.user_ids = [cls.user1.id, cls.user2.id, cls.user3.id] + cls.team = cls.teams[0] + + def test_invalid_assign_user_to_team_single(self): + # Non-existing user case + action = AssignUsersToTeamAction( + {"user_ids": [0], "team_id": self.team.id}, + ) + self.run_action(action, should_throw=True) + + # Non-existing team case + action.variables["user_ids"] = [self.user1.id] + action.variables["team_id"] = len(self.teams) + 2 + self.run_action(action, should_throw=True) + + # User already assigned in the team case + UserInTeam.objects.create(user=self.user1, team=self.team) + action.variables["user_ids"] = [self.user1.id] + action.variables["team_id"] = self.team.id + self.run_action(action, should_throw=True) + + # User with invalid group + action.variables["user_ids"] = [self.instructor.id] + action.variables["team_id"] = self.team.id + self.run_action(action, should_throw=True) + + def test_invalid_assign_user_to_team_multiple(self): + # Non-existing user case + action = AssignUsersToTeamAction( + {"user_ids": [1, 2, 13], "team_id": self.team.id}, + ) + self.run_action(action, should_throw=True) + + # Non-existing team case + action.variables["user_ids"] = self.user_ids + action.variables["team_id"] = len(self.teams) + 2 + self.run_action(action, should_throw=True) + + # Users already assigned in the team case + UserInTeam.objects.create(user=self.user1, team=self.team) + UserInTeam.objects.create(user=self.user2, team=self.team) + action.variables["user_ids"] = self.user_ids + action.variables["team_id"] = self.team.id + self.run_action(action, should_throw=True) + + # User with invalid group + action.variables["user_ids"] = [self.instructor.id, self.instructor2.id] + action.variables["team_id"] = self.team.id + self.run_action(action, should_throw=True) + + def test_valid_assign_user_to_team_single(self): + action = AssignUsersToTeamAction( + {"user_ids": [self.user1.id], "team_id": self.team.id}, + ) + self.run_action(action, user=self.instructor) + self.assertTrue( + UserInTeam.objects.filter(user=self.user1, team=self.team).exists() + ) + + def test_valid_assign_user_to_team_multiple(self): + action = AssignUsersToTeamAction( + {"user_ids": self.user_ids, "team_id": self.team.id}, + ) + self.run_action(action, user=self.instructor) + self.assertTrue( + UserInTeam.objects.filter( + user_id__in=self.user_ids, team=self.team + ).count() + == 3 + ) + + def test_invalid_remove_user_from_team_single(self): + UserInTeam.objects.create(user=self.user1, team=self.team) + # Non-existing team case + action = RemoveUsersFromTeamAction( + {"user_ids": self.user1.id, "team_id": len(self.teams) + 2}, + ) + self.run_action(action, should_throw=True) + + # Non-existing user case + action.variables["user_ids"] = [13] + action.variables["team_id"] = self.team.id + self.run_action(action, should_throw=True) + + # Users are not assigned in the team case (user2 is not assigned) + action.variables["user_ids"] = [self.user2.id] + action.variables["team_id"] = self.team.id + self.run_action(action, should_throw=True) + + def test_invalid_remove_user_from_team_multiple(self): + UserInTeam.objects.create(user=self.user1, team=self.team) + UserInTeam.objects.create(user=self.user2, team=self.team) + # Non-existing team case + action = RemoveUsersFromTeamAction( + {"user_ids": self.user_ids, "team_id": len(self.teams) + 2}, + ) + self.run_action(action, should_throw=True) + + # Non-existing user case + action.variables["user_ids"] = [1, 2, 13] + action.variables["team_id"] = self.team.id + self.run_action(action, should_throw=True) + + # Users are not assigned in the team case (user3 is not assigned) + action.variables["user_ids"] = self.user_ids + action.variables["team_id"] = self.team.id + self.run_action(action, should_throw=True) + + def test_valid_remove_user_from_team_single(self): + UserInTeam.objects.create(user=self.user1, team=self.team) + action = RemoveUsersFromTeamAction( + {"user_ids": [self.user1.id], "team_id": self.team.id}, + ) + self.run_action(action, user=self.instructor) + self.assertFalse( + UserInTeam.objects.filter( + user_id=self.user1.id, team=self.team + ).exists() + ) + + def test_valid_remove_user_from_team_multiple(self): + UserInTeam.objects.create(user=self.user1, team=self.team) + UserInTeam.objects.create(user=self.user2, team=self.team) + UserInTeam.objects.create(user=self.user3, team=self.team) + action = RemoveUsersFromTeamAction( + {"user_ids": self.user_ids, "team_id": self.team.id}, + ) + self.run_action(action, user=self.instructor) + self.assertFalse( + UserInTeam.objects.filter( + user_id__in=self.user_ids, team=self.team + ).exists() + ) + + def test_valid_definition_access_assigning(self): + action = AddDefinitionAccessAction( + { + "user_ids": [self.instructor2.id], + "definition_id": self.definition.id, + } + ) + # instructor has access to the definition thus can assign others + self.run_action(action, user=self.instructor) + self.assertTrue( + DefinitionAccess.objects.filter( + user=self.instructor2, definition=self.definition + ) + ) + DefinitionAccess.objects.filter( + user=self.instructor2, definition=self.definition + ).delete() + + # admin can access all definitions (does not need definition access) + self.run_action(action, user=self.admin) + self.assertTrue( + DefinitionAccess.objects.filter( + user=self.instructor2, definition=self.definition + ) + ) + DefinitionAccess.objects.filter( + user=self.instructor2, definition=self.definition + ).delete() + + def test_invalid_definition_access_assigning(self): + action = AddDefinitionAccessAction( + { + "user_ids": [self.instructor2.id], + "definition_id": self.definition.id, + } + ) + # trainee can't assign users to definition + self.run_action(action, should_throw=True, user=self.user1) + + # trying to assing non-existent user + action.variables["user_ids"] = [uuid.uuid4()] + self.run_action(action, should_throw=True, user=self.instructor) + + # trying to assign non-existent definition + action.variables["definition_id"] = 420 + self.run_action(action, should_throw=True, user=self.instructor) + + def test_valid_definition_access_removing(self): + action = RemoveDefinitionAccessAction( + { + "user_ids": [self.instructor2.id], + "definition_id": self.definition.id, + } + ) + DefinitionAccess.objects.create( + user=self.instructor2, definition=self.definition + ) + + # instructor with definition access can remove other instructors + self.run_action(action, user=self.instructor) + + action.variables["user_ids"] = [self.instructor.id] + # admin can remove instructors from any definition access + self.run_action(action, user=self.admin) + + DefinitionAccess.objects.create( + user=self.instructor, definition=self.definition + ) + + def test_invalid_definition_access_removing(self): + action = RemoveDefinitionAccessAction( + { + "user_ids": [self.instructor2.id], + "definition_id": self.definition.id, + } + ) + # trying to remove user not assigned to definition + self.run_action(action, should_throw=True, user=self.instructor) + + action.variables["user_ids"] = [self.instructor.id] + # trainee can't remove users from definition + self.run_action(action, should_throw=True, user=self.user1) + + # instructor without definition access can't remove others + self.run_action(action, should_throw=True, user=self.instructor2) diff --git a/user/tests/mutation_validators_tests.py b/user/tests/mutation_validators_tests.py new file mode 100644 index 00000000..f4dac319 --- /dev/null +++ b/user/tests/mutation_validators_tests.py @@ -0,0 +1,127 @@ +import uuid +import os +import shutil + +from django.test import TestCase, override_settings + +from django.conf import settings +from user.models import User, UserInTeam, InstructorOfExercise +from aai.models import UserGroup +from exercise.models import Exercise, Team +from user.schema.validators import ( + _check_nonexistent_users, + _check_group, + _check_presence, +) +from common_lib.test_utils import ( + internal_create_exercise, + internal_upload_definition, +) +from common_lib.exceptions import ModelNotFoundException +from common_lib.utils import get_model + +TEST_DATA_STORAGE = "user_tests_test_data" + + +@override_settings( + DATA_STORAGE=TEST_DATA_STORAGE, + FILE_STORAGE=os.path.join(TEST_DATA_STORAGE, "files"), +) +class UserMutationValidatorsTest(TestCase): + @classmethod + def tearDownClass(cls): + shutil.rmtree(settings.DATA_STORAGE) + super().tearDownClass() + + @classmethod + def setUpTestData(cls): + cls.user1 = User.objects.create_user( + username="u1@mail.com", password="u1" + ) + cls.user2 = User.objects.create_user( + username="u2@mail.com", password="u2" + ) + cls.user3 = User.objects.create_user( + username="u3@mail.com", password="u3" + ) + cls.instructor = User.objects.create_staffuser( + username="i@mail.com", password="i" + ) + cls.instructor2 = User.objects.create_staffuser( + username="i2@mail.com", password="i2" + ) + cls.definition = internal_upload_definition("base_definition") + cls.exercise = internal_create_exercise(cls.definition.id, 3) + cls.team = cls.exercise.teams.all().first() + + def test_nonexistent_users_checker(self): + self.assertIsNotNone( + _check_nonexistent_users([self.user1.id, self.user2.id]) + ) + with self.assertRaises(ValueError): + _check_nonexistent_users([uuid.uuid4(), uuid.uuid4()]) + + def test_existance_checker(self): + self.assertIsNotNone(get_model(Exercise, id=self.exercise.id)) + nonexistent_exercise = str(Exercise.objects.all().count() + 1) + with self.assertRaises(ModelNotFoundException): + get_model(Exercise, id=nonexistent_exercise) + + self.assertIsNotNone( + get_model(Team, id=self.exercise.teams.all().first().id) + ) + with self.assertRaises(ModelNotFoundException): + get_model(Team, id="10") + + def test_group_checker(self): + self.assertIsNone(_check_group([self.user1], [UserGroup.TRAINEE])) + with self.assertRaises(ValueError): + _check_group([self.user1], [UserGroup.INSTRUCTOR]) + + self.assertIsNone( + _check_group([self.instructor], [UserGroup.INSTRUCTOR]) + ) + with self.assertRaises(ValueError): + _check_group([self.instructor], [UserGroup.ADMIN]) + + def test_presence_checker(self): + UserInTeam.objects.create(user=self.user1, team=self.team) + _check_presence( + UserInTeam, + [self.user1], + self.team, + user_id__in=[self.user1.id], + team_id=self.team.id, + ) + team2 = self.exercise.teams.all()[1] + + # user1 was not assigned to team 2 + with self.assertRaises(ValueError): + _check_presence( + UserInTeam, + [self.user1], + team2, + user_id__in=[self.user1.id], + team_id=team2.id, + ) + + InstructorOfExercise.objects.create( + user=self.instructor, exercise=self.exercise + ) + _check_presence( + InstructorOfExercise, + [self.instructor], + self.exercise, + user_id__in=[self.instructor.id], + exercise_id=self.exercise.id, + ) + + # instructor2 was not assigned to exercise + with self.assertRaises(ValueError): + _check_presence( + InstructorOfExercise, + [self.instructor2], + self.exercise, + user_id__in=[self.instructor2.id], + exercise_id=self.exercise.id, + ) diff --git a/user/urls.py b/user/urls.py new file mode 100644 index 00000000..8f8e7d64 --- /dev/null +++ b/user/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from user import views + +urlpatterns = [ + path( + "user/create-users", + views.UploadUserFile.as_view(), + name="upload-users", + ), +] diff --git a/user/views.py b/user/views.py new file mode 100644 index 00000000..bbd663e2 --- /dev/null +++ b/user/views.py @@ -0,0 +1,50 @@ +from drf_yasg2 import openapi +from drf_yasg2.utils import swagger_auto_schema +from rest_framework import parsers +from rest_framework.request import Request +from rest_framework.views import APIView + +from common_lib.exceptions import ApiException +from aai.utils import protected +from aai.models import Perms +from user.lib import UserUploader + + +class UploadUserFile(APIView): + parser_classes = [parsers.MultiPartParser] + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="file", + in_=openapi.IN_FORM, + type=openapi.TYPE_FILE, + description="User list in CSV format", + ), + ] + ) + @protected(Perms.update_user.full_name) + def post(self, request: Request, *args, **kwargs): + """ + Uploads a new users file and checks for its correctness. + In consequence User objects are created based on the content of the file + if valid, else error response is created. + """ + uploaded_file = request.FILES.get("file") + if not uploaded_file: + raise ApiException("A csv file must be uploaded") + + if uploaded_file.content_type not in [ + "text/csv", + ]: + raise ApiException("File has to be in csv format") + + if request.user.is_anonymous: # with @protected should never happen + raise ValueError( + "Not logged in user or user without role called UploadUserFile" + ) + + return UserUploader.validate_and_upload( + uploaded_file=uploaded_file, + admin_initiator=request.user.is_superuser, + ) -- GitLab