feat: allow authorized task deletion

This commit is contained in:
MS.Kondratev
2026-07-15 13:55:02 +05:00
parent 4dd08624f7
commit 961f52ea82
6 changed files with 49 additions and 2 deletions
+29
View File
@@ -37,6 +37,35 @@ class TaskAccessTests(TestCase):
response = self.client.get(reverse("task-edit", args=(self.task.pk,))) response = self.client.get(reverse("task-edit", args=(self.task.pk,)))
self.assertEqual(response.status_code, 403) 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): def test_create_copies_rate_and_calculates_rounded_amount(self):
self.client.force_login(self.worker) self.client.force_login(self.worker)
response = self.client.post(reverse("task-create"), { response = self.client.post(reverse("task-create"), {
+1
View File
@@ -6,4 +6,5 @@ urlpatterns = [
path("", views.task_list, name="task-list"), path("", views.task_list, name="task-list"),
path("create/", views.task_create, name="task-create"), path("create/", views.task_create, name="task-create"),
path("<int:pk>/edit/", views.task_edit, name="task-edit"), path("<int:pk>/edit/", views.task_edit, name="task-edit"),
path("<int:pk>/delete/", views.task_delete, name="task-delete"),
] ]
+12
View File
@@ -59,3 +59,15 @@ def task_edit(request, pk):
task.save() task.save()
return redirect("task-list") return redirect("task-list")
return render(request, "tasks/task_form.html", {"form": form, "heading": "Редактирование задачи", "task": task}) 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})
+1 -1
View File
@@ -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}
+5
View File
@@ -0,0 +1,5 @@
{% extends 'base.html' %}{% block content %}<section class="card narrow"><h1>Удаление задачи</h1>
<p>Вы действительно хотите удалить задачу <strong>«{{ task.title }}»</strong> от {{ task.work_date|date:'d.m.Y' }}?</p>
<p class="warning">Задача будет удалена безвозвратно и перестанет отображаться в отчётах.</p>
<form method="post">{% csrf_token %}<div class="form-actions"><a href="{% url 'task-edit' task.pk %}">Отмена</a><button class="danger">Удалить</button></div></form>
</section>{% endblock %}
+1 -1
View File
@@ -10,4 +10,4 @@
<label>Минуты{{ form.duration_minutes_input }}{{ form.duration_minutes_input.errors }}</label> <label>Минуты{{ form.duration_minutes_input }}{{ form.duration_minutes_input.errors }}</label>
</fieldset> </fieldset>
<p>{{ form.status.label_tag }}{{ form.status }}{{ form.status.errors }}</p> <p>{{ form.status.label_tag }}{{ form.status }}{{ form.status.errors }}</p>
<button>Сохранить</button> <a href="{% url 'task-list' %}">Отмена</a></form></section>{% endblock %} <div class="form-actions"><div><button>Сохранить</button> <a href="{% url 'task-list' %}">Отмена</a></div>{% if task %}<a class="button danger" href="{% url 'task-delete' task.pk %}">Удалить задачу</a>{% endif %}</div></form></section>{% endblock %}