diff --git a/client/src/components/WidgetFooter.vue b/client/src/components/WidgetFooter.vue index 9e1f1099..52c232b5 100644 --- a/client/src/components/WidgetFooter.vue +++ b/client/src/components/WidgetFooter.vue @@ -1,14 +1,13 @@ @@ -17,7 +16,6 @@ import WidgetPopover from '@/components/rooms/WidgetPopover'; export default { - props: ['on-delete', 'on-edit', 'id', 'entity'], components: { Ellipses, @@ -28,6 +26,12 @@ return { showMenu: false } + }, + + methods: { + toggleMenu: function () { + this.showMenu = !this.showMenu + } } } diff --git a/client/src/components/portfolio/EditProject.vue b/client/src/components/portfolio/EditProject.vue new file mode 100644 index 00000000..343c7a53 --- /dev/null +++ b/client/src/components/portfolio/EditProject.vue @@ -0,0 +1,41 @@ + + + diff --git a/client/src/components/portfolio/ProjectWidget.vue b/client/src/components/portfolio/ProjectWidget.vue index de355da8..9f6ff647 100644 --- a/client/src/components/portfolio/ProjectWidget.vue +++ b/client/src/components/portfolio/ProjectWidget.vue @@ -3,13 +3,17 @@

{{title}}

- - - + +
- + + + @@ -19,7 +23,7 @@ import WidgetFooter from '@/components/WidgetFooter'; export default { - props: ['title', 'appearance', 'slug'], + props: ['title', 'appearance', 'slug', 'id', 'final', 'student', 'entriesCount', 'userId'], components: { WidgetFooter, @@ -28,8 +32,27 @@ }, computed: { - widgetClass() { + widgetClass () { return `project-widget--${this.appearance}`; + }, + isOwner () { + return this.student.id === this.userId; + }, + owner () { + return `${this.student.firstName} ${this.student.lastName}` + } + }, + + methods: { + share: function (scope) { + this.updateShare(scope, true); + }, + unshare: function (scope) { + this.updateShare(scope, false); + }, + updateShare: function (scope, state) { + scope.hide(); + this.$emit('updateShare', this.id, state); } } } diff --git a/client/src/components/rooms/RoomEntry.vue b/client/src/components/rooms/RoomEntry.vue index a7e3ebe1..f591459e 100644 --- a/client/src/components/rooms/RoomEntry.vue +++ b/client/src/components/rooms/RoomEntry.vue @@ -4,13 +4,13 @@ - + v-if="showMenu"> + + +
diff --git a/client/src/components/rooms/RoomWidget.vue b/client/src/components/rooms/RoomWidget.vue index 6cd4d648..2374589b 100644 --- a/client/src/components/rooms/RoomWidget.vue +++ b/client/src/components/rooms/RoomWidget.vue @@ -5,13 +5,9 @@ - + + +
@@ -58,27 +54,28 @@ }, methods: { - deleteRoom(id) { + deleteRoom() { + const theId = this.id this.$apollo.mutate({ mutation: DELETE_ROOM_MUTATION, variables: { input: { - id + id: theId } }, update(store, {data: {deleteRoom: {success}}}) { if (success) { const data = store.readQuery({query: ROOMS_QUERY}); if (data) { - data.rooms.edges.splice(data.rooms.edges.findIndex(edge => edge.node.id === id), 1); + data.rooms.edges.splice(data.rooms.edges.findIndex(edge => edge.node.id === theId), 1); store.writeQuery({query: ROOMS_QUERY, data}); } } } }) }, - editRoom(id) { - this.$router.push({name: 'edit-room', params: {id: id}}); + editRoom() { + this.$router.push({name: 'edit-room', params: {id: this.id}}); } } } diff --git a/client/src/components/rooms/WidgetPopover.vue b/client/src/components/rooms/WidgetPopover.vue index 68f0963c..5df364e0 100644 --- a/client/src/components/rooms/WidgetPopover.vue +++ b/client/src/components/rooms/WidgetPopover.vue @@ -1,14 +1,13 @@ diff --git a/client/src/pages/portfolio.vue b/client/src/pages/portfolio.vue index 00c90076..f7a15708 100644 --- a/client/src/pages/portfolio.vue +++ b/client/src/pages/portfolio.vue @@ -5,11 +5,15 @@ - @@ -17,7 +21,10 @@ import ProjectWidget from '@/components/portfolio/ProjectWidget'; import AddProject from '@/components/portfolio/AddProject'; + import ME_QUERY from '@/graphql/gql/meQuery.gql'; import PROJECTS_QUERY from '@/graphql/gql/allProjects.gql'; + import DELETE_PROJECT_MUTATION from '@/graphql/gql/mutations/deleteProject.gql'; + import UPDATE_PROJECT_MUTATION from '@/graphql/gql/mutations/updateProject.gql'; export default { components: { @@ -32,11 +39,65 @@ return this.$getRidOfEdges(data).projects } }, + me: { + query: ME_QUERY + } }, - data() { + data () { return { - projects: [] + projects: [], + me: {} + } + }, + + computed: { + userId () { + return this.me.id; + } + }, + + methods: { + deleteProject(id) { + this.$apollo.mutate({ + mutation: DELETE_PROJECT_MUTATION, + variables: { + input: { + id + } + }, + update(store, {data: {deleteProject: {success}}}) { + if (success) { + const data = store.readQuery({query: PROJECTS_QUERY}); + + if (data) { + data.projects.edges.splice(data.projects.edges.findIndex(edge => edge.node.id === id), 1); + store.writeQuery({query: PROJECTS_QUERY, data}); + } + } + } + }) + }, + editProject(id) { + this.$router.push({name: 'edit-project', params: { id }}); + }, + updateShareState(id, state) { + const project = this.projects.filter(project => project.id === id)[0]; + this.$apollo.mutate({ + mutation: UPDATE_PROJECT_MUTATION, + variables: { + input: { + project: { + id: project.id, + title: project.title, + description: project.description, + appearance: project.appearance, + objectives: project.objectives, + final: state + } + } + } + }) } } } diff --git a/client/src/router/index.js b/client/src/router/index.js index 3256ddf3..e70eedc9 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -18,6 +18,7 @@ import start from '@/pages/start' import submission from '@/pages/studentSubmission' import portfolio from '@/pages/portfolio' import project from '@/pages/project' +import editProject from '@/pages/editProject' import newProject from '@/pages/newProject' import store from '@/store/index'; @@ -60,6 +61,7 @@ const routes = [ {path: '/portfolio', name: 'portfolio', component: portfolio}, {path: '/portfolio/:slug', name: 'project', component: project, props: true}, {path: '/new-project/', name: 'new-project', component: newProject}, + {path: '/edit-project/:id', name: 'edit-project', component: editProject, props: true}, { path: '/book', name: 'book', diff --git a/server/core/settings_test.py b/server/core/settings_test.py index 1ad6e870..a480a60b 100644 --- a/server/core/settings_test.py +++ b/server/core/settings_test.py @@ -10,3 +10,7 @@ class DisableMigrations(object): MIGRATION_MODULES = DisableMigrations() + +# Email Settings +SENDGRID_API_KEY = "" +EMAIL_BACKEND = 'django.core.mail.backends.dummy.EmailBackend' diff --git a/server/portfolio/factories.py b/server/portfolio/factories.py new file mode 100644 index 00000000..4432b5fc --- /dev/null +++ b/server/portfolio/factories.py @@ -0,0 +1,17 @@ +import random + +import factory + +from core.factories import fake +from portfolio.models import Project, ProjectEntry + + +class ProjectFactory(factory.django.DjangoModelFactory): + class Meta: + model = Project + + objectives = factory.LazyAttribute(lambda x: fake.sentence(nb_words=random.randint(4, 8))) + title = factory.LazyAttribute(lambda x: fake.sentence(nb_words=random.randint(4, 8))) + appearance = factory.LazyAttribute(lambda x: random.choice(['red', 'green', 'yellow'])) + final = False + diff --git a/server/portfolio/inputs.py b/server/portfolio/inputs.py index 584b8018..ecfda7a3 100644 --- a/server/portfolio/inputs.py +++ b/server/portfolio/inputs.py @@ -15,6 +15,7 @@ class AddProjectArgument(ProjectInput): class UpdateProjectArgument(ProjectInput): id = graphene.ID(required=True) + final = graphene.Boolean() class ProjectEntryInput(InputObjectType): @@ -29,3 +30,4 @@ class AddProjectEntryArgument(ProjectEntryInput): class UpdateProjectEntryArgument(ProjectEntryInput): id = graphene.ID(required=True) + diff --git a/server/portfolio/migrations/0003_auto_20190325_1452.py b/server/portfolio/migrations/0003_auto_20190325_1452.py new file mode 100644 index 00000000..a04c9f33 --- /dev/null +++ b/server/portfolio/migrations/0003_auto_20190325_1452.py @@ -0,0 +1,27 @@ +# Generated by Django 2.0.6 on 2019-03-25 14:52 + +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), + ('portfolio', '0002_projectentry'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='final', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='project', + name='student', + field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/server/portfolio/models.py b/server/portfolio/models.py index 32ca421b..9a601463 100644 --- a/server/portfolio/models.py +++ b/server/portfolio/models.py @@ -1,3 +1,4 @@ +from django.contrib.auth import get_user_model from django.db import models from django_extensions.db.models import TitleSlugDescriptionModel @@ -5,6 +6,8 @@ from django_extensions.db.models import TitleSlugDescriptionModel class Project(TitleSlugDescriptionModel): objectives = models.TextField(blank=True) appearance = models.CharField(blank=True, null=False, max_length=255) + student = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='projects') + final = models.BooleanField(default=False) def __str__(self): return self.title diff --git a/server/portfolio/mutations.py b/server/portfolio/mutations.py index bbb6ebc6..8e6dee3f 100644 --- a/server/portfolio/mutations.py +++ b/server/portfolio/mutations.py @@ -1,5 +1,6 @@ import graphene from graphene import relay +from rest_framework.exceptions import PermissionDenied from api.utils import get_object from portfolio.inputs import AddProjectArgument, UpdateProjectArgument, AddProjectEntryArgument, \ @@ -31,7 +32,6 @@ from portfolio.serializers import ProjectSerializer, ProjectEntrySerializer # # return cls(errors=['{}: {}'.format(key, value) for key, value in serializer.errors.items()]) - class MutateProject(relay.ClientIDMutation): errors = graphene.List(graphene.String) project = graphene.Field(ProjectNode) @@ -41,8 +41,10 @@ class MutateProject(relay.ClientIDMutation): # serializer_class = ProjectSerializer @classmethod - def mutate_and_get_payload(cls, *args, **kwargs): + def mutate_and_get_payload(cls, root, info, **kwargs): data = kwargs.get('project') + data['student'] = info.context.user.id + if data.get('id') is not None: entity = get_object(Project, data['id']) serializer = ProjectSerializer(entity, data=data) @@ -64,6 +66,19 @@ class AddProject(MutateProject): project = graphene.Argument( AddProjectArgument) # NB: can't be named AddProjectInput, otherwise graphene complains + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + + data = kwargs.get('project') + data['student'] = info.context.user.id + + serializer = ProjectSerializer(data=data) + if serializer.is_valid(): + serializer.save() + return cls(project=serializer.instance) + + return cls(room=None, errors=['{}: {}'.format(key, value) for key, value in serializer.errors.items()]) + class UpdateProject(MutateProject): class Input: @@ -102,8 +117,29 @@ class UpdateProjectEntry(MutateProjectEntry): project_entry = graphene.Argument(UpdateProjectEntryArgument) +class DeleteProject(relay.ClientIDMutation): + class Input: + id = graphene.ID(required=True) + + success = graphene.Boolean() + errors = graphene.List(graphene.String) + + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + id = kwargs.get('id') + project = get_object(Project, id) + user = info.context.user + + if project.student != user: + raise PermissionDenied('Permission denied: Incorrect project') + project.delete() + return cls(success=True) + + class PortfolioMutations: add_project = AddProject.Field() update_project = UpdateProject.Field() + delete_project = DeleteProject.Field() add_project_entry = AddProjectEntry.Field() update_project_entry = UpdateProjectEntry.Field() + diff --git a/server/portfolio/schema.py b/server/portfolio/schema.py index 2edf9f91..780ff14b 100644 --- a/server/portfolio/schema.py +++ b/server/portfolio/schema.py @@ -5,10 +5,12 @@ from graphene_django.filter import DjangoFilterConnectionField from api.utils import get_by_id_or_slug from portfolio.models import Project, ProjectEntry +from users.models import UserRole, Role class ProjectNode(DjangoObjectType): pk = graphene.Int() + entries_count = graphene.Int() class Meta: model = Project @@ -18,6 +20,9 @@ class ProjectNode(DjangoObjectType): def resolve_pk(self, *args, **kwargs): return self.id + def resolve_entries_count(self, *args, **kwargs): + return self.entries.count() + class ProjectEntryNode(DjangoObjectType): class Meta: @@ -30,7 +35,14 @@ class PortfolioQuery(object): projects = DjangoFilterConnectionField(ProjectNode) def resolve_projects(self, info, **kwargs): - return Project.objects.all().order_by('-pk') + user = info.context.user + if user.is_superuser: + return Project.objects.all().order_by('-pk') + + if UserRole.get_role_for_user(user).role == Role.objects.get_default_teacher_role(): + return Project.objects.filter(student__school_classes__in=user.school_classes.all(), final=True) + + return Project.objects.filter(student=user) def resolve_project(self, info, **kwargs): return get_by_id_or_slug(Project, **kwargs) diff --git a/server/portfolio/serializers.py b/server/portfolio/serializers.py index ed186aa8..dc19d3b3 100644 --- a/server/portfolio/serializers.py +++ b/server/portfolio/serializers.py @@ -6,7 +6,7 @@ from portfolio.models import Project, ProjectEntry class ProjectSerializer(serializers.ModelSerializer): class Meta: model = Project - fields = ('id', 'title', 'description', 'objectives', 'slug', 'appearance',) + fields = ('id', 'title', 'description', 'objectives', 'slug', 'appearance', 'student', 'final',) read_only_fields = ('id', 'slug',) diff --git a/server/portfolio/tests/__init__.py b/server/portfolio/tests/__init__.py index e69de29b..336672d4 100644 --- a/server/portfolio/tests/__init__.py +++ b/server/portfolio/tests/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 26.03.19 +# @author: chrigu +from django.conf import settings diff --git a/server/portfolio/tests/test_project_mutations.py b/server/portfolio/tests/test_project_mutations.py index 30e9e215..ddcd3638 100644 --- a/server/portfolio/tests/test_project_mutations.py +++ b/server/portfolio/tests/test_project_mutations.py @@ -1,6 +1,63 @@ +from django.test import TestCase, RequestFactory +from graphene.test import Client +from graphql_relay import to_global_id + +from api.schema import schema +from portfolio.factories import ProjectFactory +from users.factories import SchoolClassFactory +from users.models import User +from users.services import create_users from api.test_utils import create_client, DefaultUserTestCase from portfolio.models import Project +class ProjectQuery(TestCase): + def setUp(self): + create_users() + self.teacher = User.objects.get(username='teacher') + self.teacher2 = User.objects.get(username='teacher2') + self.student = User.objects.get(username='student1') + self.student2 = User.objects.get(username='student2') + school_class1 = SchoolClassFactory(users=[self.teacher, self.student]) + school_class2 = SchoolClassFactory(users=[self.teacher2, self.student2]) + self.project1 = ProjectFactory(student=self.student) + + self.mutation = ''' + mutation DeleteProject($input: DeleteProjectInput!) { + deleteProject(input: $input) { + success + errors + } + } + ''' + + self.variables = { + 'input': { + 'id': to_global_id('ProjectNode', self.project1.id) + } + } + + def test_should_be_able_to_delete_own_projects(self): + + self.assertEqual(Project.objects.count(), 1) + request = RequestFactory().get('/') + request.user = self.student + self.client = Client(schema=schema, context_value=request) + + result = self.client.execute(self.mutation, variables=self.variables) + + self.assertIsNone(result.get('errors')) + self.assertEqual(Project.objects.count(), 0) + + def test_should_not_be_able_to_delete_other_projects(self): + + self.assertEqual(Project.objects.count(), 1) + request = RequestFactory().get('/') + request.user = self.student2 + self.client = Client(schema=schema, context_value=request) + + result = self.client.execute(self.mutation, variables=self.variables) + self.assertEqual(result.get('errors')[0]['message'], 'Permission denied: Incorrect project') + class ProjectMutationsTestCase(DefaultUserTestCase): def test_add_project(self): @@ -28,3 +85,4 @@ class ProjectMutationsTestCase(DefaultUserTestCase): }) self.assertIsNone(result.get('errors')) self.assertEqual(Project.objects.count(), 1) + diff --git a/server/portfolio/tests/test_project_query.py b/server/portfolio/tests/test_project_query.py new file mode 100644 index 00000000..4ff1d7a4 --- /dev/null +++ b/server/portfolio/tests/test_project_query.py @@ -0,0 +1,120 @@ +from django.test import TestCase, RequestFactory +from graphene.test import Client +from graphql_relay import to_global_id + +from api.schema import schema +from portfolio.factories import ProjectFactory +from portfolio.models import Project +from rooms.models import Room +from users.factories import SchoolClassFactory +from users.models import User, SchoolClass +from users.services import create_users + + +class ProjectQuery(TestCase): + def setUp(self): + create_users() + self.teacher = User.objects.get(username='teacher') + self.teacher2 = User.objects.get(username='teacher2') + self.student = User.objects.get(username='student1') + self.student2 = User.objects.get(username='student2') + school_class1 = SchoolClassFactory(users=[self.teacher, self.student]) + school_class2 = SchoolClassFactory(users=[self.teacher2, self.student2]) + self.project1 = ProjectFactory(student=self.student) + self.query = ''' + query ProjectsQuery { + projects { + edges { + node { + ...ProjectParts + __typename + } + __typename + } + __typename + } + } + + fragment ProjectParts on ProjectNode { + id + title + appearance + description + slug + objectives + __typename + } + + ''' + + def test_should_see_own_projects(self): + self.assertEqual(Project.objects.count(), 1) + request = RequestFactory().get('/') + request.user = self.student + self.client = Client(schema=schema, context_value=request) + + result = self.client.execute(self.query) + + self.assertIsNone(result.get('errors')) + self.assertEqual(result.get('data').get('projects').get('edges')[0].get('node').get('title'), self.project1.title) + + def test_should_not_see_other_projects(self): + self.assertEqual(Project.objects.count(), 1) + request = RequestFactory().get('/') + request.user = self.student2 + self.client = Client(schema=schema, context_value=request) + + result = self.client.execute(self.query) + + self.assertIsNone(result.get('errors')) + self.assertEqual(len(result.get('data').get('projects').get('edges')), 0) + + def test_teacher_should_not_see_unfinished_projects(self): + request = RequestFactory().get('/') + request.user = self.teacher + self.client = Client(schema=schema, context_value=request) + + result = self.client.execute(self.query) + + self.assertIsNone(result.get('errors')) + self.assertEqual(len(result.get('data').get('projects').get('edges')), 0) + + def test_teacher_should_only_see_finished_projects(self): + self.project1.final = True + self.assertEqual(Project.objects.count(), 1) + request = RequestFactory().get('/') + request.user = self.teacher + self.client = Client(schema=schema, context_value=request) + + result = self.client.execute(self.query) + + self.assertIsNone(result.get('errors')) + self.assertEqual(result.get('data').get('projects').get('edges')[0].get('node').get('title'), + self.project1.title) + + def test_teacher_should_only_see_finished_projects(self): + self.project1.final = True + self.project1.save() + self.assertEqual(Project.objects.count(), 1) + request = RequestFactory().get('/') + request.user = self.teacher + self.client = Client(schema=schema, context_value=request) + + result = self.client.execute(self.query) + + self.assertIsNone(result.get('errors')) + self.assertEqual(result.get('data').get('projects').get('edges')[0].get('node').get('title'), + self.project1.title) + + def test_other_teacher_should_not_see_projects(self): + self.project1.final = True + self.project1.save() + self.assertEqual(Project.objects.count(), 1) + request = RequestFactory().get('/') + request.user = self.teacher2 + self.client = Client(schema=schema, context_value=request) + + result = self.client.execute(self.query) + + self.assertIsNone(result.get('errors')) + self.assertEqual(len(result.get('data').get('projects').get('edges')), 0) diff --git a/server/rooms/schema.py b/server/rooms/schema.py index 74f6a217..556c98b4 100644 --- a/server/rooms/schema.py +++ b/server/rooms/schema.py @@ -11,6 +11,7 @@ from users.schema import UserNode logger = logging.getLogger(__name__) + class RoomEntryNode(DjangoObjectType): pk = graphene.Int() author = UserNode() diff --git a/server/rooms/tests/test_room_delete_edit_permissions.py b/server/rooms/tests/test_room_delete_edit_permissions.py index 9ef3833d..b0ea2bd5 100644 --- a/server/rooms/tests/test_room_delete_edit_permissions.py +++ b/server/rooms/tests/test_room_delete_edit_permissions.py @@ -37,7 +37,6 @@ class RoomDeleteEditPermissionsTestcase(TestCase): request.user = self.teacher self.client = Client(schema=schema, context_value=request) - result = self.client.execute(self.mutation, variables=self.variables) self.assertIsNone(result.get('errors')) diff --git a/server/users/services.py b/server/users/services.py index 60b796fc..b075cc2d 100644 --- a/server/users/services.py +++ b/server/users/services.py @@ -34,9 +34,6 @@ def create_users(data=None): name='second_class' ) - - - else: for school_class in data: first, last = school_class.get('teacher')