Skip to content

Commit 3ec0b9a

Browse files
authored
fix: do not allow verifying & moving reports at the same time (#1124)
* fix(backend): do not allow verifying & moving reports at the same time * fix(frontend/analysis): validate verified field
1 parent 53db909 commit 3ec0b9a

File tree

6 files changed

+186
-36
lines changed

6 files changed

+186
-36
lines changed

backend/timed/tracking/tests/test_report.py

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Tests for the reports endpoint."""
22

3+
from __future__ import annotations
4+
35
from datetime import date, timedelta
6+
from typing import TYPE_CHECKING
47

58
import pyexcel
69
import pytest
@@ -16,6 +19,10 @@
1619
TaskFactory,
1720
)
1821

22+
if TYPE_CHECKING:
23+
from timed.projects.models import Project, Task
24+
from timed.tracking.models import Report
25+
1926

2027
def test_report_list(
2128
internal_employee_client,
@@ -1245,46 +1252,49 @@ def test_report_export_max_count(
12451252
assert response.status_code == expected_status
12461253

12471254

1248-
def test_report_update_bulk_verify_reviewer_multiple_notify(
1255+
def test_report_update_bulk_reviewer_multiple_notify(
12491256
internal_employee_client,
1250-
task,
1257+
task: Task,
12511258
task_factory,
1252-
project,
1259+
project: Project,
12531260
report_factory,
12541261
user_factory,
1262+
project_assignee_factory,
12551263
mailoutbox,
12561264
):
12571265
reviewer = internal_employee_client.user
1258-
ProjectAssigneeFactory.create(user=reviewer, project=project, is_reviewer=True)
1266+
project_assignee_factory(user=reviewer, project=project, is_reviewer=True)
12591267

12601268
user1, user2, user3 = user_factory.create_batch(3)
12611269
report1_1 = report_factory(user=user1, task=task)
12621270
report1_2 = report_factory(user=user1, task=task)
12631271
report2 = report_factory(user=user2, task=task)
12641272
report3 = report_factory(user=user3, task=task)
12651273

1266-
other_task = task_factory()
1274+
other_task = task_factory(project=project)
12671275

12681276
url = reverse("report-bulk")
12691277

12701278
data = {
12711279
"data": {
12721280
"type": "report-bulks",
12731281
"id": None,
1274-
"attributes": {"verified": True, "comment": "some comment"},
1282+
"attributes": {"comment": "some comment"},
12751283
"relationships": {"task": {"data": {"type": "tasks", "id": other_task.id}}},
12761284
}
12771285
}
12781286

1279-
query_params = f"?editable=1&reviewer={reviewer.id}&id=" + ",".join(
1280-
str(r.id) for r in [report1_1, report1_2, report2, report3]
1281-
)
1282-
response = internal_employee_client.post(url + query_params, data)
1287+
reports = [report1_1, report1_2, report2, report3]
1288+
params = {
1289+
"editable": 1,
1290+
"reviewer": reviewer.id,
1291+
"id": ",".join(str(r.id) for r in reports),
1292+
}
1293+
response = internal_employee_client.post(url, data, query_params=params)
12831294
assert response.status_code == status.HTTP_204_NO_CONTENT
12841295

1285-
for report in [report1_1, report1_2, report2, report3]:
1296+
for report in reports:
12861297
report.refresh_from_db()
1287-
assert report.verified_by == reviewer
12881298
assert report.comment == "some comment"
12891299
assert report.task == other_task
12901300

@@ -2111,3 +2121,47 @@ def test_report_list_filter_comment(
21112121
assert json["data"][0]["id"] == str(report_1.id)
21122122
assert json["data"][1]["id"] == str(report_2.id)
21132123
assert json["data"][2]["id"] == str(report_3.id)
2124+
2125+
2126+
def test_report_bulk_edit_move_and_verify(
2127+
internal_employee_client, user, report_factory, project_assignee_factory, task: Task
2128+
):
2129+
reviewer = internal_employee_client.user
2130+
2131+
assert reviewer != user
2132+
2133+
# create a report owned by another user
2134+
report: Report = report_factory(user=user)
2135+
2136+
# assign the test employee as reviewer to the project of the report
2137+
project_assignee_factory(
2138+
user=reviewer, project=report.task.project, is_reviewer=True
2139+
)
2140+
2141+
url = reverse("report-bulk")
2142+
2143+
# raw request body for verifying and moving a report at the same time
2144+
data = {
2145+
"data": {
2146+
"type": "report-bulks",
2147+
"id": None,
2148+
"attributes": {"verified": True},
2149+
"relationships": {
2150+
"task": {"data": {"type": "tasks", "id": task.pk}},
2151+
},
2152+
}
2153+
}
2154+
2155+
# query params as required by the action
2156+
params = {"editable": 1, "reviewer": reviewer.id, "id": report.id}
2157+
2158+
response = internal_employee_client.post(
2159+
url,
2160+
data=data,
2161+
query_params=params,
2162+
)
2163+
assert response.status_code == status.HTTP_400_BAD_REQUEST
2164+
assert (
2165+
response.json()["errors"][0]["detail"]
2166+
== "Reports can't be moved and verified at the same time."
2167+
)

backend/timed/tracking/views.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,11 @@ def bulk(self, request):
256256
_("Reports can't both be set as `review` and `verified`.")
257257
)
258258

259+
if fields.get("task"):
260+
raise exceptions.ParseError(
261+
_("Reports can't be moved and verified at the same time.")
262+
)
263+
259264
if serializer.validated_data.get("billed", None) is not None and not (
260265
user.is_superuser or user.is_accountant
261266
):

frontend/app/analysis/edit/template.hbs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,12 @@
209209
</Checkbox>
210210
</f.input>
211211

212-
<f.input @name="verified" @labelComponent="void" as |fi|>
212+
<f.input
213+
@name="verified"
214+
@labelComponent="void"
215+
@dirty={{true}}
216+
as |fi|
217+
>
213218
<Checkbox
214219
data-test-verified
215220
@checked={{fi.value}}
Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,57 @@
1-
import validateIntersectionTask from "timed/validators/intersection-task";
1+
/**
2+
* Given `changes` and `content` from a changeset validator, return wether a given `relation` has been changed.
3+
* @param {string} relation
4+
* @returns {(changes: Record<string, unknown>, content: Record<string, unknown>) => boolean}
5+
*/
6+
const relationChanged = (relation) => (changes, content) =>
7+
Object.keys(changes).includes(relation) &&
8+
(changes[relation]?.get("id") || null) !==
9+
(content[relation]?.get("id") || null);
10+
11+
const customerChanged = relationChanged("customer");
12+
const projectChanged = relationChanged("project");
13+
14+
const validateVerified = (key, newValue, oldValue, changes, content) => {
15+
if (!newValue) {
16+
// when verified is false, it is valid
17+
return true;
18+
}
19+
20+
// the task can be null when verifying multiple reports, therefore we
21+
// also validate against a change in customer/project
22+
if (
23+
(changes.task?.id && content.task?.id !== changes.task?.id) ||
24+
customerChanged(changes, content) ||
25+
projectChanged(changes, content)
26+
) {
27+
return "Cannot verify and move report(s) at the same time.";
28+
}
29+
30+
return true;
31+
};
32+
33+
const validateIntersectionTask = (
34+
key,
35+
newValue,
36+
oldValue,
37+
changes,
38+
content,
39+
) => {
40+
const hasTask = !!(newValue && newValue.id);
41+
42+
// different customer -> different project -> different task
43+
// a report belongs to a task, therefore when customer/project are changed
44+
// task is no longer optional
45+
return (
46+
hasTask ||
47+
(!hasTask &&
48+
!customerChanged(changes, content) &&
49+
!projectChanged(changes, content)) ||
50+
"Task must not be empty"
51+
);
52+
};
253

354
export default {
4-
task: validateIntersectionTask(),
55+
verified: validateVerified,
56+
task: validateIntersectionTask,
557
};

frontend/app/validators/intersection-task.js

Lines changed: 0 additions & 21 deletions
This file was deleted.

frontend/tests/acceptance/analysis-edit-test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { click, fillIn, currentURL, visit } from "@ember/test-helpers";
22
import { setupMirage } from "ember-cli-mirage/test-support";
3+
import { selectChoose } from "ember-power-select/test-support";
34
import { setupApplicationTest } from "ember-qunit";
45
import { authenticateSession } from "ember-simple-auth/test-support";
56
import { module, test } from "qunit";
@@ -149,4 +150,58 @@ module("Acceptance | analysis edit", function (hooks) {
149150
"Please select yourself as 'reviewer' to verify reports. Please review selected reports before verifying.",
150151
);
151152
});
153+
test("cannot verify report and move it at the same time", async function (assert) {
154+
// we set the name of the task to something that faker will not produce
155+
// otherwise this is flaky as tasks with the same name could exist (and we select them in the dropdown by name)
156+
const task = this.server.create("task", { name: "the task" });
157+
const otherTasks = this.server.createList("task", 10);
158+
159+
// giving `project`/`projectId` as an argument to `createList` does not work
160+
// this is a workaround
161+
otherTasks.forEach((t) => {
162+
t.update({ project: task.project });
163+
});
164+
165+
this.reportIntersection.update({
166+
review: false,
167+
customer: task.project.customer,
168+
project: task.project,
169+
taskId: task.id,
170+
});
171+
await visit(`/analysis/edit?id=${task.id}`);
172+
173+
// verified checkbox is not disabled
174+
assert.dom("[data-test-verified] input").isNotDisabled();
175+
assert.dom("[data-test-verified] label").hasAttribute("title", "");
176+
177+
// select another task
178+
await selectChoose("[data-test-task]", otherTasks.at(-1).name);
179+
180+
// set verified to `true`
181+
await click("[data-test-verified] input");
182+
183+
// the error message is visible
184+
assert
185+
.dom("[data-test-verified] + .invalid-feedback")
186+
.hasText("Cannot verify and move report(s) at the same time.");
187+
188+
// set verified back to`false`
189+
await click("[data-test-verified] input");
190+
// the error message no longer exists (as there is no error)
191+
assert.dom("[data-test-verified] + .invalid-feedback").doesNotExist();
192+
193+
// set verified back to `true`
194+
await click("[data-test-verified] input");
195+
196+
// the error message is visible again
197+
assert
198+
.dom("[data-test-verified] + .invalid-feedback")
199+
.hasText("Cannot verify and move report(s) at the same time.");
200+
201+
// select the original task
202+
await selectChoose("[data-test-task]", task.name);
203+
204+
// the error message is gone
205+
assert.dom("[data-test-verified] + .invalid-feedback").doesNotExist();
206+
});
152207
});

0 commit comments

Comments
 (0)