From 7b4c7e01a49629fa8a9770471710b1ee81e08abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20S=C3=BCckr?= Date: Sat, 30 Nov 2024 13:33:12 +0100 Subject: [PATCH 1/6] feat: add editorconfig --- .editorconfig | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..866761c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_size = 4 +indent_style = space + +[*.{yaml,yml}] +indent_size = 2 +indent_style = space From 1722e6e7609a729821da48ed92e4a4e6a9156c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20S=C3=BCckr?= Date: Sat, 30 Nov 2024 13:33:35 +0100 Subject: [PATCH 2/6] feat: add Docker Compose config --- docker-compose.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4109255 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: "3.8" +services: + redis: + image: redis:alpine + ports: + - 6379:6379 + postgres: + environment: + POSTGRES_USER: comrade + POSTGRES_PASSWORD: comrade + POSTGRES_DB: comrade + image: postgres:alpine + ports: + - 5432:5432 + volumes: + - postgres-data:/var/lib/postgresql/data +volumes: + postgres-data: From 057d3db0fff6cc0216731b0cf34b9520ff6aedfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20S=C3=BCckr?= Date: Sat, 30 Nov 2024 13:33:56 +0100 Subject: [PATCH 3/6] feat(admin): add skills to User admin --- comrade/comrade_core/admin.py | 40 +++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/comrade/comrade_core/admin.py b/comrade/comrade_core/admin.py index fb5833a..b36004a 100644 --- a/comrade/comrade_core/admin.py +++ b/comrade/comrade_core/admin.py @@ -1,13 +1,41 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin -from .models import User, Task, Skill +from django.contrib.auth.forms import UserChangeForm + +from .models import Skill, Task, User + + +class UserChangeForm(UserChangeForm): + class Meta(UserChangeForm.Meta): + model = User + + +class ComradeUserAdmin(UserAdmin): + form = UserChangeForm + fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("skills",)}),) + class TaskAdmin(admin.ModelAdmin): - list_display = ['name', 'owner', 'state', 'lat', 'lon', 'respawn', 'respawn_time', 'base_value', 'criticality', 'contribution'] - list_filter = ['state', 'respawn', 'criticality'] - search_fields = ['name', 'description'] + list_display = [ + "name", + "owner", + "state", + "lat", + "lon", + "respawn", + "respawn_time", + "base_value", + "criticality", + "contribution", + ] + list_filter = ["state", "respawn", "criticality"] + search_fields = ["name", "description"] + + +class SkillInline(admin.StackedInline): + model = Skill -admin.site.register(User, UserAdmin) +admin.site.register(User, ComradeUserAdmin) admin.site.register(Task, TaskAdmin) -admin.site.register(Skill) \ No newline at end of file +admin.site.register(Skill) From e2db2ea718ed00119ef4e109434f1b842e7d4491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20S=C3=BCckr?= Date: Sat, 30 Nov 2024 13:36:30 +0100 Subject: [PATCH 4/6] feat(task): check if user has required permissions before starting task --- comrade/comrade_core/models.py | 107 ++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 35 deletions(-) diff --git a/comrade/comrade_core/models.py b/comrade/comrade_core/models.py index 0e7d159..437d574 100644 --- a/comrade/comrade_core/models.py +++ b/comrade/comrade_core/models.py @@ -1,15 +1,17 @@ import datetime + +from django.contrib.auth.models import AbstractUser +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils.timezone import now -from django.core.validators import MaxValueValidator, MinValueValidator -from django.contrib.auth.models import AbstractUser class User(AbstractUser): def __str__(self) -> str: - return self.username - - skills = models.ManyToManyField('Skill', blank=True) + return self.username + + skills = models.ManyToManyField("Skill", blank=True) latitude = models.FloatField(blank=True, default=0) longitude = models.FloatField(blank=True, default=0) @@ -18,12 +20,15 @@ def __str__(self) -> str: def has_skill(self, skill_name): return self.skills.filter(name=skill_name).exists() - + + class Skill(models.Model): name = models.CharField(max_length=32) + def __str__(self) -> str: return self.name + class Task(models.Model): class Criticality(models.IntegerChoices): LOW = 1 @@ -37,7 +42,7 @@ class State(models.IntegerChoices): WAITING = 3 IN_REVIEW = 4 DONE = 5 - + def __str__(self) -> str: return self.name @@ -46,14 +51,26 @@ def __str__(self) -> str: description = models.CharField(max_length=200, blank=True) # permissions - skill_read = models.ManyToManyField(Skill, related_name='read') - skill_write = models.ManyToManyField(Skill, related_name='write') - skill_execute = models.ManyToManyField(Skill, related_name='execute') + skill_read = models.ManyToManyField(Skill, related_name="read") + skill_write = models.ManyToManyField(Skill, related_name="write") + skill_execute = models.ManyToManyField(Skill, related_name="execute") # task state state = models.IntegerField(choices=State, default=1, blank=True) - owner = models.ForeignKey('comrade_core.User', null=True, blank=True, on_delete=models.RESTRICT, related_name='owned_tasks') - assignee = models.ForeignKey('comrade_core.User', null=True, blank=True, on_delete=models.RESTRICT, related_name='assigned_tasks') + owner = models.ForeignKey( + "comrade_core.User", + null=True, + blank=True, + on_delete=models.RESTRICT, + related_name="owned_tasks", + ) + assignee = models.ForeignKey( + "comrade_core.User", + null=True, + blank=True, + on_delete=models.RESTRICT, + related_name="assigned_tasks", + ) # location lat = models.FloatField(null=True, blank=True) @@ -66,29 +83,41 @@ def __str__(self) -> str: # values base_value = models.FloatField(blank=True, null=True) criticality = models.IntegerField(choices=Criticality, default=1) - contribution = models.FloatField(blank=True, null=True, validators=[MaxValueValidator(1.0), MinValueValidator(0.0)]) + contribution = models.FloatField( + blank=True, + null=True, + validators=[MaxValueValidator(1.0), MinValueValidator(0.0)], + ) # time tracking - minutes = models.IntegerField(default=10, validators=[MaxValueValidator(480), MinValueValidator(1)]) + minutes = models.IntegerField( + default=10, validators=[MaxValueValidator(480), MinValueValidator(1)] + ) datetime_start = models.DateTimeField(auto_now_add=False, blank=True, null=True) - datetime_finish = models.DateTimeField(auto_now_add=False, blank=True, null=True) + datetime_finish = models.DateTimeField(auto_now_add=False, blank=True, null=True) - def start(self, user): - self.state = 2 + def start(self, user: User): + has_required_skills = user.skills.filter( + id__in=self.skill_execute.all() + ).exists() + if not has_required_skills: + raise ValidationError("User does not have required skills") + + self.state = Task.State.IN_PROGRESS self.datetime_start = now() - self.owner = user + self.assignee = user self.save() def pause(self): - if self.state != 2: # Check if the task is currently in progress + if self.state != Task.State.IN_PROGRESS: return False - self.state = 3 # Set state to WAITING + self.state = Task.State.WAITING self.save() def resume(self): - if self.state != 3: # Check if the task is currently in WAITING state + if self.state != Task.State.WAITING: return False - self.state = 2 # Set state back to IN_PROGRESS + self.state = Task.State.IN_PROGRESS self.save() def finish(self): @@ -96,34 +125,42 @@ def finish(self): self.save() def rate(self): - if self.state != 2: + if self.state != Task.State.IN_PROGRESS: return False r = Rating() r.task = self r.save() - self.state=4 + self.state = Task.State.IN_REVIEW self.save() def review(self): - if self.state != 4: + if self.state != Task.State.IN_REVIEW: return False r = Review(done=1) r.task = self r.save() - self.state=5 + self.state = Task.State.DONE self.save() -class Rating(models.Model): - task = models.ForeignKey('comrade_core.Task', default=None, on_delete=models.RESTRICT, blank=True) - happiness = models.FloatField(default = 1) - time = models.FloatField(default = 1) +class Rating(models.Model): + task = models.ForeignKey( + "comrade_core.Task", default=None, on_delete=models.RESTRICT, blank=True + ) + happiness = models.FloatField(default=1) + time = models.FloatField(default=1) def __str__(self) -> str: - return "Rating of task \""+ self.task + "\"" - + return 'Rating of task "' + self.task + '"' + + class Review(models.Model): - task = models.ForeignKey('comrade_core.Task', default=None, on_delete=models.RESTRICT, blank=True) - done = models.FloatField(validators=[MaxValueValidator(1.0), MinValueValidator(0.0)]) + task = models.ForeignKey( + "comrade_core.Task", default=None, on_delete=models.RESTRICT, blank=True + ) + done = models.FloatField( + validators=[MaxValueValidator(1.0), MinValueValidator(0.0)] + ) + def __str__(self) -> str: - return "Review of task \""+ self.task + "\"" \ No newline at end of file + return 'Review of task "' + self.task + '"' From 20334e4dfa2c32085c585638137558006546d4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20S=C3=BCckr?= Date: Sat, 30 Nov 2024 13:48:31 +0100 Subject: [PATCH 5/6] feat: only task assignee and owner can update the task --- comrade/comrade_core/models.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/comrade/comrade_core/models.py b/comrade/comrade_core/models.py index 437d574..d38fb04 100644 --- a/comrade/comrade_core/models.py +++ b/comrade/comrade_core/models.py @@ -108,23 +108,35 @@ def start(self, user: User): self.assignee = user self.save() - def pause(self): + def pause(self, user: User): + if user != self.owner or user != self.assignee: + raise ValidationError("Only owner and assignee can pause the task") + if self.state != Task.State.IN_PROGRESS: return False self.state = Task.State.WAITING self.save() - def resume(self): + def resume(self, user: User): + if user != self.owner or user != self.assignee: + raise ValidationError("Only owner and assignee can resume the task") + if self.state != Task.State.WAITING: return False self.state = Task.State.IN_PROGRESS self.save() - def finish(self): + def finish(self, user: User): + if user != self.owner or user != self.assignee: + raise ValidationError("Only owner and assignee can finish the task") + self.datetime_finish = now() self.save() - def rate(self): + def rate(self, user: User): + if user != self.owner or user != self.assignee: + raise ValidationError("Only owner and assignee can rate the task") + if self.state != Task.State.IN_PROGRESS: return False r = Rating() @@ -133,7 +145,10 @@ def rate(self): self.state = Task.State.IN_REVIEW self.save() - def review(self): + def review(self, user: User): + if user != self.owner: + raise ValidationError("Only owner can review the task") + if self.state != Task.State.IN_REVIEW: return False r = Review(done=1) From aab7f7f9704708319b72f6e78e72b492219724a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20S=C3=BCckr?= Date: Sat, 30 Nov 2024 14:04:36 +0100 Subject: [PATCH 6/6] feat: simple test for Task.start --- comrade/comrade_core/tests.py | 38 ++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/comrade/comrade_core/tests.py b/comrade/comrade_core/tests.py index 7ce503c..495f89c 100644 --- a/comrade/comrade_core/tests.py +++ b/comrade/comrade_core/tests.py @@ -1,3 +1,39 @@ +from django.core.exceptions import ValidationError from django.test import TestCase -# Create your tests here. +from comrade_core.models import Skill, Task, User + + +class TaskTestCase(TestCase): + def test_task_start_throws_error_to_user_with_no_skill(self): + s = Skill.objects.create(name="tasktestcase1") + u = User.objects.create(username="tasktestcase") + t = Task.objects.create() + t.skill_execute.add(s) + + self.assertTrue(t.skill_execute.count() == 1) + self.assertTrue(u.skills.count() == 0) + + try: + t.start(u) + except ValidationError: + pass + else: + self.fail( + "start should throw an error when the user has not the required skill for execute" + ) + + def test_task_start_succeeds_when_user_has_required_execute_skills(self): + s = Skill.objects.create(name="tasktestcase1") + u = User.objects.create(username="tasktestcase") + t = Task.objects.create() + t.skill_execute.add(s) + u.skills.add(s) + + self.assertTrue(t.skill_execute.count() == 1) + self.assertTrue(u.skills.count() == 1) + + try: + t.start(u) + except ValidationError: + self.fail("start should pass when user has at least one required skill")