diff --git a/apps/tasks/test_tasks.py b/apps/tasks/test_tasks.py index 8e23da0..1429d2a 100644 --- a/apps/tasks/test_tasks.py +++ b/apps/tasks/test_tasks.py @@ -37,6 +37,35 @@ class TaskAccessTests(TestCase): response = self.client.get(reverse("task-edit", args=(self.task.pk,))) self.assertEqual(response.status_code, 403) + def test_worker_can_delete_own_task(self): + own_task = Task.objects.create( + project=self.project, assignee=self.worker, title="Своя задача", description="Описание", + work_date=date(2026, 7, 14), duration_minutes=60, hourly_rate=Decimal("1000.00"), + amount=Decimal("1000.00"), created_by=self.worker, + ) + self.client.force_login(self.worker) + response = self.client.post(reverse("task-delete", args=(own_task.pk,))) + self.assertRedirects(response, reverse("task-list")) + self.assertFalse(Task.objects.filter(pk=own_task.pk).exists()) + + def test_worker_cannot_delete_another_users_task(self): + self.client.force_login(self.worker) + response = self.client.post(reverse("task-delete", args=(self.task.pk,))) + self.assertEqual(response.status_code, 403) + self.assertTrue(Task.objects.filter(pk=self.task.pk).exists()) + + def test_admin_can_delete_any_task(self): + self.client.force_login(self.admin) + response = self.client.post(reverse("task-delete", args=(self.task.pk,))) + self.assertRedirects(response, reverse("task-list")) + self.assertFalse(Task.objects.filter(pk=self.task.pk).exists()) + + def test_delete_confirmation_does_not_delete_task(self): + self.client.force_login(self.other) + response = self.client.get(reverse("task-delete", args=(self.task.pk,))) + self.assertContains(response, "Удаление задачи") + self.assertTrue(Task.objects.filter(pk=self.task.pk).exists()) + def test_create_copies_rate_and_calculates_rounded_amount(self): self.client.force_login(self.worker) response = self.client.post(reverse("task-create"), { diff --git a/apps/tasks/urls.py b/apps/tasks/urls.py index 4d57ac5..f1c6c71 100644 --- a/apps/tasks/urls.py +++ b/apps/tasks/urls.py @@ -6,4 +6,5 @@ urlpatterns = [ path("", views.task_list, name="task-list"), path("create/", views.task_create, name="task-create"), path("/edit/", views.task_edit, name="task-edit"), + path("/delete/", views.task_delete, name="task-delete"), ] diff --git a/apps/tasks/views.py b/apps/tasks/views.py index 1c383dd..9175735 100644 --- a/apps/tasks/views.py +++ b/apps/tasks/views.py @@ -59,3 +59,15 @@ def task_edit(request, pk): task.save() return redirect("task-list") return render(request, "tasks/task_form.html", {"form": form, "heading": "Редактирование задачи", "task": task}) + + +@login_required +@transaction.atomic +def task_delete(request, pk): + task = get_object_or_404(Task.objects.select_related("project__company", "assignee"), pk=pk) + if not request.user.is_app_admin and task.assignee_id != request.user.id: + raise PermissionDenied + if request.method == "POST": + task.delete() + return redirect("task-list") + return render(request, "tasks/task_confirm_delete.html", {"task": task}) diff --git a/static/app.css b/static/app.css index 7c66705..0a36d17 100644 --- a/static/app.css +++ b/static/app.css @@ -1 +1 @@ -:root{font-family:Inter,Segoe UI,Arial,sans-serif;color:#192033;background:#f4f6fa}*{box-sizing:border-box}body{margin:0}header{height:64px;background:#172033;color:white;display:flex;align-items:center;padding:0 max(24px,calc((100% - 1200px)/2));gap:36px}header a{color:white;text-decoration:none}.brand{font-size:22px;font-weight:800}nav{display:flex;align-items:center;gap:20px;margin-left:auto}nav form{margin:0}.link{background:none;border:0;color:white;padding:0}main{max-width:1200px;margin:32px auto;padding:0 24px}h1{margin-top:0}.card{background:white;border-radius:10px;padding:28px;box-shadow:0 3px 16px #18243b12}.narrow{max-width:480px;margin:60px auto}.toolbar,.filters{display:flex;align-items:center;gap:16px;justify-content:space-between}.filters{justify-content:flex-start;background:white;padding:16px;border-radius:8px;margin-bottom:22px;flex-wrap:wrap}label{font-weight:600}input,select,textarea{display:block;width:100%;padding:9px;border:1px solid #bac2d0;border-radius:6px;margin-top:5px}button,.button{border:0;border-radius:6px;background:#2f67dc;color:white;padding:10px 16px;text-decoration:none;cursor:pointer;font-weight:600}.secondary{background:#5a6477}table{width:100%;border-collapse:collapse;background:white}th,td{padding:11px;border-bottom:1px solid #dde2ea;text-align:left;vertical-align:top}th{background:#e9edf4}.table-wrap{overflow:auto}small{color:#647086}.total,.company-total,.grand-total{font-weight:700}.grand-total{font-size:20px;text-align:right}.report-company{margin:24px 0 36px}.errorlist{color:#b42318}.duration-fields{display:flex;gap:16px;border:0;padding:0;margin:20px 0}.duration-fields legend{font-weight:700;margin-bottom:8px;width:100%}.duration-fields label{width:150px}.duration-fields input{min-width:0} +:root{font-family:Inter,Segoe UI,Arial,sans-serif;color:#192033;background:#f4f6fa}*{box-sizing:border-box}body{margin:0}header{height:64px;background:#172033;color:white;display:flex;align-items:center;padding:0 max(24px,calc((100% - 1200px)/2));gap:36px}header a{color:white;text-decoration:none}.brand{font-size:22px;font-weight:800}nav{display:flex;align-items:center;gap:20px;margin-left:auto}nav form{margin:0}.link{background:none;border:0;color:white;padding:0}main{max-width:1200px;margin:32px auto;padding:0 24px}h1{margin-top:0}.card{background:white;border-radius:10px;padding:28px;box-shadow:0 3px 16px #18243b12}.narrow{max-width:480px;margin:60px auto}.toolbar,.filters,.form-actions{display:flex;align-items:center;gap:16px;justify-content:space-between}.filters{justify-content:flex-start;background:white;padding:16px;border-radius:8px;margin-bottom:22px;flex-wrap:wrap}label{font-weight:600}input,select,textarea{display:block;width:100%;padding:9px;border:1px solid #bac2d0;border-radius:6px;margin-top:5px}button,.button{border:0;border-radius:6px;background:#2f67dc;color:white;padding:10px 16px;text-decoration:none;cursor:pointer;font-weight:600}.secondary{background:#5a6477}.danger{background:#b42318}.warning{color:#8f1d14}table{width:100%;border-collapse:collapse;background:white}th,td{padding:11px;border-bottom:1px solid #dde2ea;text-align:left;vertical-align:top}th{background:#e9edf4}.table-wrap{overflow:auto}small{color:#647086}.total,.company-total,.grand-total{font-weight:700}.grand-total{font-size:20px;text-align:right}.report-company{margin:24px 0 36px}.errorlist{color:#b42318}.duration-fields{display:flex;gap:16px;border:0;padding:0;margin:20px 0}.duration-fields legend{font-weight:700;margin-bottom:8px;width:100%}.duration-fields label{width:150px}.duration-fields input{min-width:0} diff --git a/templates/tasks/task_confirm_delete.html b/templates/tasks/task_confirm_delete.html new file mode 100644 index 0000000..94f74cd --- /dev/null +++ b/templates/tasks/task_confirm_delete.html @@ -0,0 +1,5 @@ +{% extends 'base.html' %}{% block content %}

Удаление задачи

+

Вы действительно хотите удалить задачу «{{ task.title }}» от {{ task.work_date|date:'d.m.Y' }}?

+

Задача будет удалена безвозвратно и перестанет отображаться в отчётах.

+
{% csrf_token %}
Отмена
+
{% endblock %} diff --git a/templates/tasks/task_form.html b/templates/tasks/task_form.html index 79aecaa..5591104 100644 --- a/templates/tasks/task_form.html +++ b/templates/tasks/task_form.html @@ -10,4 +10,4 @@

{{ form.status.label_tag }}{{ form.status }}{{ form.status.errors }}

- Отмена{% endblock %} +
Отмена
{% if task %}Удалить задачу{% endif %}
{% endblock %}