From 8f169f41bdca5adfcb93164bd68730d23995bfc2 Mon Sep 17 00:00:00 2001 From: Mitry Date: Wed, 13 Mar 2024 17:52:04 -0300 Subject: [PATCH 1/3] feat: date selector --- .../src/components/FilesValidate/index.jsx | 50 +++++-- .../forms/ValidationFilterGroup/index.jsx | 57 ++++---- .../ui/DateSelector/component.test.js | 3 + .../src/components/ui/DateSelector/index.jsx | 98 ++++++++++++++ .../src/components/ui/DateSelector/styles.css | 124 ++++++++++++++++++ 5 files changed, 294 insertions(+), 38 deletions(-) create mode 100644 frontend-app/src/components/ui/DateSelector/component.test.js create mode 100644 frontend-app/src/components/ui/DateSelector/index.jsx create mode 100644 frontend-app/src/components/ui/DateSelector/styles.css diff --git a/frontend-app/src/components/FilesValidate/index.jsx b/frontend-app/src/components/FilesValidate/index.jsx index fba222f..dd9d7c9 100644 --- a/frontend-app/src/components/FilesValidate/index.jsx +++ b/frontend-app/src/components/FilesValidate/index.jsx @@ -39,27 +39,33 @@ export default function FilesValidation({ pathID, attributes, canValidate }) { const navigate = useNavigate(); function getPageQuery() { + var from = pageQuery.get("date_from"); + var to = pageQuery.get("date_to"); + return { card: pageQuery.getAll('card[]'), attr: pageQuery.getAll('attr[]'), type: pageQuery.getAll('type[]'), author: pageQuery.getAll('author[]'), + date: (from || to) && { from, to } }; } function handleChange(filterType, query) { - var { card, attr, type, author } = getPageQuery(); + var { card, attr, type, author, date } = getPageQuery(); setPageQuery({ 'card[]': filterType === 'card' ? query : card, 'attr[]': filterType === 'attr' ? query : attr, 'type[]': filterType === 'type' ? query : type, 'author[]': filterType === 'author' ? query : author, + "date_from": filterType === "date" ? query.from : date.from, + "date_to": filterType === "date" ? query.to : date.to, }); } useEffect(() => { - var { card, attr, type, author } = getPageQuery(); + var { card, attr, type, author, date } = getPageQuery(); if (!card.length) handleChange("card", ['v']); @@ -69,15 +75,24 @@ export default function FilesValidation({ pathID, attributes, canValidate }) { params: { card, attr, type, author }, headers: { "Authorization": "Bearer " + localStorage.getItem("dtcAccess") } }), - canValidate && api.get(`api/users/collectors/${pathID}/`, { - headers: { "Authorization": "Bearer " + localStorage.getItem("dtcAccess") } - }) + canValidate && api.get(`api/users/collectors/${pathID}/`, { + headers: { "Authorization": "Bearer " + localStorage.getItem("dtcAccess") } + }) ]) .then(([fileFetch, authorFetch]) => { var { value: { data } } = fileFetch; fileManager.initFiles(data); sliderManager.setMax(data.length); + /** + * @type {{ + * prettyName: string, + * name: string, + * data?: Array, + * selected: Array | object, + * type?: string + * }[]} + */ var _filterData = [ { prettyName: 'Card Filter:', @@ -90,7 +105,7 @@ export default function FilesValidation({ pathID, attributes, canValidate }) { name: 'attr', data: attributes, selected: attr, - attributes: true + type: "attr" }, { prettyName: 'Filetype Filter:', @@ -100,15 +115,22 @@ export default function FilesValidation({ pathID, attributes, canValidate }) { }, ]; - if (canValidate) _filterData.push({ - prettyName: 'Author Filter:', - name: 'author', - data: authorFetch.value.data.map(({id, username}) => ({ name: username, id })), - selected: author, - }); + if (canValidate) _filterData.push(...[ + { + prettyName: "Author Filter:", + name: "author", + data: authorFetch.value.data.map(({id, username}) => ({ name: username, id })), + selected: author, + }, + { + prettyName: "Date Filter:", + name: "date", + selected: date, + type: "date", + } + ]); setFilterData(_filterData); - setLoading(false); }) .catch(({ message, response }) => { @@ -124,7 +146,7 @@ export default function FilesValidation({ pathID, attributes, canValidate }) { return ( <> - + { fileManager.files.length ?
diff --git a/frontend-app/src/components/forms/ValidationFilterGroup/index.jsx b/frontend-app/src/components/forms/ValidationFilterGroup/index.jsx index 76913b4..f6666d8 100644 --- a/frontend-app/src/components/forms/ValidationFilterGroup/index.jsx +++ b/frontend-app/src/components/forms/ValidationFilterGroup/index.jsx @@ -1,8 +1,37 @@ import { ReactElement } from "react"; import MultiSelector from "../../ui/MultiSelector"; +import DateSelector from "../../ui/DateSelector"; import AttributeMultiSelector from "../../ui/AttributeMultiSelector"; import './styles.css'; +/** +* @param {object} props +* @param {object} props.filter +* @param {Function} props.onChange +* @returns {ReactElement | void} +*/ +function FilterSwitch ({ filter, onChange }) { + var { name, data, selected, type } = filter; + switch (type) { + case "attr": return ; + case "date": return onChange(name, date)} + />; + default: return onChange(name, ids)} + />; + } +} + /** * @param {object} props * @param {object[]} props.filterData @@ -13,30 +42,10 @@ export default function ValidationFilterGroup({ filterData, onChange }) { return (
{ - filterData.map(({ - prettyName, - name, - data, - selected, - attributes - }) => ( -
- - { - attributes - ? - : onChange(name, ids)} - defaultSelected={selected} - /> - } + filterData.map((filter) => ( +
+ +
)) } diff --git a/frontend-app/src/components/ui/DateSelector/component.test.js b/frontend-app/src/components/ui/DateSelector/component.test.js new file mode 100644 index 0000000..28b629f --- /dev/null +++ b/frontend-app/src/components/ui/DateSelector/component.test.js @@ -0,0 +1,3 @@ +import { fireEvent, act, render, renderHook, screen } from '@testing-library/react'; + +test("date selector test", () => {}); diff --git a/frontend-app/src/components/ui/DateSelector/index.jsx b/frontend-app/src/components/ui/DateSelector/index.jsx new file mode 100644 index 0000000..6ca6c18 --- /dev/null +++ b/frontend-app/src/components/ui/DateSelector/index.jsx @@ -0,0 +1,98 @@ +import { ReactElement, useEffect, useState } from 'react'; +import './styles.css'; + +/** +* @param {object} props +* @param {Function} props.onChange +* @param {{ from: string, to: string }} [props.defaultSelected] +* @returns {ReactElement} +*/ +export default function DateSelector({ + onChange, + defaultSelected, +}) { + const [isOpen, setIsOpen] = useState(false); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + + function resetSelector() { + setFrom(""); + setTo(""); + onChange({}); + } + + function handleManualSelect() { + setIsOpen(false); + onChange({ from, to }); + } + + function handleFrom({ target }) { + var check = !to || new Date(target.value) < new Date(to); + setFrom(check ? target.value : ""); + } + + function handleTo({ target }) { + var check = !from || new Date(from) < new Date(target.value); + setTo(check ? target.value : ""); + } + + useEffect(() => { + if (defaultSelected) { + var { from: _from, to: _to } = defaultSelected; + _from && setFrom(_from); + _to && setTo(_to); + } + }, [defaultSelected]); + + return ( +
+
setIsOpen(!isOpen)} + className='iss__dateSelector__selected' + > + { + (from || to) + ? {from || "..."} - {to || "..."} + : -not set- + } + + + +
+
+
+ + + + +
+
+
+ ); +} diff --git a/frontend-app/src/components/ui/DateSelector/styles.css b/frontend-app/src/components/ui/DateSelector/styles.css new file mode 100644 index 0000000..5da6e3b --- /dev/null +++ b/frontend-app/src/components/ui/DateSelector/styles.css @@ -0,0 +1,124 @@ +.iss__dateSelector { + position: relative; + width: 100%; + padding: 4px 8px; + background-color: white; + border: 1px solid var(--issBrand); + height: fit-content; +} + +.iss__dateSelector__selected { + position: relative; + cursor: pointer; + display: inline-block; + width: 100%; + height: 100%; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.iss__dateSelector__selected > span { + padding: 2px 4px; + color: black; + font-size: 12px; + background-color: var(--issLightBrand); + border-radius: 4px; + white-space: nowrap; +} + +.off--title { + background-color: transparent !important; + font-size: 14px; +} + +.iss__dateSelector__selected > svg { + position: absolute; + fill: var(--issBrand); + top: 50%; + right: 0; + transform: translate(-50%, -50%); +} + +.iss__dateSelector__options { + position: absolute; + bottom: 0; + left: 0; + z-index: 1; + width: 100%; + overflow-y: hidden; + background-color: white; + max-height: 0; + outline: 1px solid transparent; + transform: translateY(100%); +} + +.iss__dateSelector__options > div { + padding: 4px 8px; + display: flex; + flex-direction: column; +} + +.options--open { + outline-color: var(--issBrand); + max-height: 500px; + transition: + max-height 0.3s ease, + outline 0.5s ease; +} + +.iss__dateSelector__level, +.iss__dateSelector__input { + font-size: 14px; + cursor: pointer; + color: black; + padding: 4px 0; + border-bottom: 1px solid var(--issBrand); + margin-bottom: 8px; +} +.iss__dateSelector__input { + margin-bottom: 4px; + display: flex; + flex-direction: column; +} +.iss__dateSelector__input > span { + font-size: 12px; + opacity: 0.5; +} + +.iss__dateSelector__selector { + border: none; + outline: none; + font-size: 14px; + cursor: text; +} + +.iss__dateSelector__submit { + margin: 12px 0; + background-color: unset; + border: 1px solid var(--issBrand); + padding: 4px; + width: fit-content; + min-width: 60px; + place-self: center; + cursor: pointer; + transition: + background-color 0.3s ease, + color 0.3s ease; +} + +@media (hover: hover) { + .iss__dateSelector__option:hover { + background-color: var(--issLightBrand); + cursor: pointer; + } + + .iss__dateSelector__level:hover { + background-color: var(--issLightBrand); + } + + .iss__dateSelector__submit:hover { + background-color: var(--issBrand); + color: white; + } +} From 1ac187cce8556f21a69872d44d85ef6d1837400d Mon Sep 17 00:00:00 2001 From: Mitry Date: Thu, 14 Mar 2024 18:46:14 -0300 Subject: [PATCH 2/3] feat-issueresolve-1 --- backend-app/file/file_tests/services_test.py | 11 +++++- backend-app/file/services.py | 20 +++++++--- frontend-app/src/app.css | 2 +- .../src/components/FilesValidate/index.jsx | 20 +++++++--- .../ValidationFilterGroup/component.test.js | 6 ++- .../ui/DateSelector/component.test.js | 38 ++++++++++++++++++- .../src/components/ui/DateSelector/index.jsx | 5 +-- 7 files changed, 81 insertions(+), 21 deletions(-) diff --git a/backend-app/file/file_tests/services_test.py b/backend-app/file/file_tests/services_test.py index c4d775a..68717f3 100644 --- a/backend-app/file/file_tests/services_test.py +++ b/backend-app/file/file_tests/services_test.py @@ -12,6 +12,7 @@ from project.models import Project from user.models import CustomUser from uuid import uuid4 +from datetime import datetime as dt, timedelta as td class ViewServicesTest(TestCase, ViewSetServices): @@ -199,6 +200,12 @@ def test_form_query(self): query["author"] = self.admin self._form_query_mixin(query) + query["from_"] = dt.now().strftime("%Y-%m-%d") + self._form_query_mixin(query) + + query["to"] = (dt.now() + td(days=1)).strftime("%Y-%m-%d") + self._form_query_mixin(query) + def _form_query_mixin(self, data={}): query, filter = self._get_query(**data) res = self._form_query( @@ -215,7 +222,9 @@ def _get_query( status="", downloaded=False, author=[], - user=None + user=None, + from_="", + to="" ): request_query = type( "query", diff --git a/backend-app/file/services.py b/backend-app/file/services.py index 7b08b55..b92a967 100644 --- a/backend-app/file/services.py +++ b/backend-app/file/services.py @@ -9,11 +9,13 @@ from rest_framework.views import Request from django.db import connection from django.db.models import Count, Subquery, QuerySet +from django.utils import timezone as tz from attribute.models import Level, AttributeGroup as AGroup from user.models import CustomUser from json import loads from typing import Any from uuid import UUID +from datetime import datetime as dt from .serializers import File, FileSerializer @@ -28,7 +30,9 @@ class ViewSetServices: ("file_type__in", "type[]", True), ("status", "status", False), ("is_downloaded", "downloaded", False), - ("author__in", "author[]", True) + ("author__in", "author[]", True), + ("upload_date__gte", "from", False), + ("upload_date__lte", "to", False) ) def _patch_file( @@ -87,13 +91,19 @@ def _form_query( else request_query.get(param) ) - if query_param: query[filter_name] = ( - False if filter_name == "is_downloaded" - else query_param - ) + if query_param: + query[filter_name] = self._get_param(filter_name, query_param) return query + def _get_param(self, filter_name: str, query_param: Any) -> Any: + date_from_str = lambda d: tz.make_aware(dt.strptime(d, "%Y-%m-%d")) + match filter_name: + case "is_downloaded": return False + case "upload_date__gte": return date_from_str(query_param) + case "upload_date__lte": return date_from_str(query_param) + case _: return query_param + def _get_files( self, project_id: int, diff --git a/frontend-app/src/app.css b/frontend-app/src/app.css index c5b6d00..c27e919 100644 --- a/frontend-app/src/app.css +++ b/frontend-app/src/app.css @@ -13,7 +13,7 @@ /* --issLightGreen: #99ff62; */ /* --issBrand: #004077; */ /* --issLightBrand: #01a4e0; */ - --maxWidth: 1320px; + --maxWidth: 1920px; } * { diff --git a/frontend-app/src/components/FilesValidate/index.jsx b/frontend-app/src/components/FilesValidate/index.jsx index dd9d7c9..5325135 100644 --- a/frontend-app/src/components/FilesValidate/index.jsx +++ b/frontend-app/src/components/FilesValidate/index.jsx @@ -53,15 +53,23 @@ export default function FilesValidation({ pathID, attributes, canValidate }) { function handleChange(filterType, query) { var { card, attr, type, author, date } = getPageQuery(); - - setPageQuery({ + var preparedQuery = { 'card[]': filterType === 'card' ? query : card, 'attr[]': filterType === 'attr' ? query : attr, 'type[]': filterType === 'type' ? query : type, 'author[]': filterType === 'author' ? query : author, - "date_from": filterType === "date" ? query.from : date.from, - "date_to": filterType === "date" ? query.to : date.to, - }); + }; + + if (filterType === "date" || date?.from) { + let value = filterType === "date" ? query?.from : date?.from; + if (value) preparedQuery.date_from = value; + } + if (filterType === "date" || date?.to) { + let value = filterType === "date" ? query?.to : date?.to; + if (value) preparedQuery.date_to = value; + } + + setPageQuery(preparedQuery); } useEffect(() => { @@ -72,7 +80,7 @@ export default function FilesValidation({ pathID, attributes, canValidate }) { // TODO: query collectors depend on uploads to project by users Promise.allSettled([ api.get(`/api/files/project/${pathID}/`, { - params: { card, attr, type, author }, + params: { card, attr, type, author, from: date?.from, to: date?.to }, headers: { "Authorization": "Bearer " + localStorage.getItem("dtcAccess") } }), canValidate && api.get(`api/users/collectors/${pathID}/`, { diff --git a/frontend-app/src/components/forms/ValidationFilterGroup/component.test.js b/frontend-app/src/components/forms/ValidationFilterGroup/component.test.js index 4678497..b03df83 100644 --- a/frontend-app/src/components/forms/ValidationFilterGroup/component.test.js +++ b/frontend-app/src/components/forms/ValidationFilterGroup/component.test.js @@ -3,8 +3,9 @@ import ValidationFilterGroup from '.'; test("validation filter group form component test", () => { const filterData = [ - { prettryName: "sel1", name: "sel1name", selected: [], attributes: true, data: [] }, - { prettryName: "sel2", name: "sek2name", selected: [], data: [] }, + { prettryName: "sel1", name: "sel1name", selected: [], type: "attr", data: [] }, + { prettryName: "sel2", name: "sel2name", selected: [], type: "date", data: [] }, + { prettryName: "sel3", name: "sek3name", selected: [], data: [] }, ]; var changed = false; const { container } = render( @@ -16,6 +17,7 @@ test("validation filter group form component test", () => { expect(container.querySelectorAll(".iss__filterSelector")).toHaveLength(1); expect(container.querySelectorAll(".iss__manualSelector")).toHaveLength(1); + expect(container.querySelectorAll(".iss__dateSelector")).toHaveLength(1); expect(changed).toBeFalsy(); fireEvent.click( container.querySelector(".iss__filterSelector") diff --git a/frontend-app/src/components/ui/DateSelector/component.test.js b/frontend-app/src/components/ui/DateSelector/component.test.js index 28b629f..87d1c5a 100644 --- a/frontend-app/src/components/ui/DateSelector/component.test.js +++ b/frontend-app/src/components/ui/DateSelector/component.test.js @@ -1,3 +1,37 @@ -import { fireEvent, act, render, renderHook, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import DateSelector from '.'; -test("date selector test", () => {}); +test("date selector test", () => { + const containerClass = ".iss__dateSelector__options"; + const parentClass = ".iss__dateSelector__selected"; + + const toggler = () => { + var opened = false; + return (set) => { + if (set) return opened = !opened; + var className = containerClass.slice(1); + fireEvent.click(container.querySelector(parentClass)); + opened = !opened; + if (opened) className += " options--open"; + expect(container.querySelector(containerClass).className).toBe(className); + return opened; + }; + }; + + const { container } = render( + e} defaultSelected={{}} /> + ); + + var toggle = toggler(); + + screen.getByText("clear dates"); + expect(toggle()).toBeTruthy(); + expect(toggle()).toBeFalsy(); + expect(toggle()).toBeTruthy(); + expect(container.querySelector(".off--title")).not.toBeNull(); + + fireEvent.click(screen.getByRole("button")); + toggle(true); + + expect(container.querySelector(containerClass).className).toBe(containerClass.slice(1)); +}); diff --git a/frontend-app/src/components/ui/DateSelector/index.jsx b/frontend-app/src/components/ui/DateSelector/index.jsx index 6ca6c18..b70dbca 100644 --- a/frontend-app/src/components/ui/DateSelector/index.jsx +++ b/frontend-app/src/components/ui/DateSelector/index.jsx @@ -7,10 +7,7 @@ import './styles.css'; * @param {{ from: string, to: string }} [props.defaultSelected] * @returns {ReactElement} */ -export default function DateSelector({ - onChange, - defaultSelected, -}) { +export default function DateSelector({ onChange, defaultSelected }) { const [isOpen, setIsOpen] = useState(false); const [from, setFrom] = useState(""); const [to, setTo] = useState(""); From cad5eab600a0ea8b697787bb503134b0d2e660d7 Mon Sep 17 00:00:00 2001 From: Mitry Date: Fri, 15 Mar 2024 12:55:13 -0300 Subject: [PATCH 3/3] feat: validator/ref: tests, filters --- backend-app/file/file_tests/models_test.py | 5 +- .../file/file_tests/serializers_test.py | 20 ++++- backend-app/file/file_tests/services_test.py | 35 +++++++- backend-app/file/models.py | 9 ++- backend-app/file/serializers.py | 19 ++++- backend-app/file/services.py | 9 ++- backend-app/file/views.py | 2 +- frontend-app/src/App.jsx | 3 +- .../common/FileModification/component.test.js | 18 +++-- .../common/FileModification/index.jsx | 8 +- .../common/FileModification/styles.css | 81 +++++++++++-------- .../common/SelectorWrap/component.test.js | 5 ++ .../common/TableBodySet/component.test.js | 21 +++++ .../common/TitleSwitch/component.test.js | 7 ++ .../components/forms/SelectorGroup/styles.css | 1 - .../ui/DateSelector/component.test.js | 28 ++++++- 16 files changed, 210 insertions(+), 61 deletions(-) diff --git a/backend-app/file/file_tests/models_test.py b/backend-app/file/file_tests/models_test.py index f839d6c..9744d11 100644 --- a/backend-app/file/file_tests/models_test.py +++ b/backend-app/file/file_tests/models_test.py @@ -36,11 +36,12 @@ def test_create_file(self): "project", "author", "status", - "is_downloaded" + "is_downloaded", + "validator", ) .get(id=new_file.id) ), - set(credentials.values()).union({'v', False}) + set(credentials.values()).union({'v', False, None}) ) self.assertEqual(init_count + 1, self.case.project.file_set.count()) diff --git a/backend-app/file/file_tests/serializers_test.py b/backend-app/file/file_tests/serializers_test.py index c53039f..ba97f93 100644 --- a/backend-app/file/file_tests/serializers_test.py +++ b/backend-app/file/file_tests/serializers_test.py @@ -32,7 +32,13 @@ def test_file_serializer(self): self.assertEqual( set(data.keys()), - data_keys.union({'upload_date', 'attributes', 'author_name'}) + data_keys.union({ + 'upload_date', + "update_date", + 'attributes', + 'author_name', + "validator_name" + }) ) self.assertEqual( {val for key, val in data.items() if key in data_keys}, @@ -42,7 +48,15 @@ def test_file_serializer(self): self.assertEqual(data["attributes"][0].keys(), {"uid", "attributes"}) def test_update_file_serializer(self): - data = FileSerializer(self.case.file_, {'status': 'a'}, partial=True) + data = FileSerializer( + self.case.file_, + {'status': 'a'}, + partial=True, + context={"validator": self.case.user} + ) + init_date = data.instance.upload_date + + self.assertIsNone(data.instance.validator) if data.is_valid(): data.update_file() @@ -50,6 +64,8 @@ def test_update_file_serializer(self): self.assertEqual(updated_data['status'], 'a') self.assertFalse(bool(updated_data['attributes'])) + self.assertIsNotNone(updated_data.get("validator_name")) + self.assertNotEqual(updated_data.get("update_date"), init_date) def test_update_attribute_file_serializer(self): self.case.attribute2 = self.case.attributegroup.attribute.create( diff --git a/backend-app/file/file_tests/services_test.py b/backend-app/file/file_tests/services_test.py index 68717f3..271e86b 100644 --- a/backend-app/file/file_tests/services_test.py +++ b/backend-app/file/file_tests/services_test.py @@ -13,9 +13,12 @@ from user.models import CustomUser from uuid import uuid4 from datetime import datetime as dt, timedelta as td +from django.utils import timezone as tz class ViewServicesTest(TestCase, ViewSetServices): + date_format: str = "%Y-%m-%d" + @classmethod def setUpClass(cls): super().setUpClass() @@ -147,9 +150,18 @@ def test_patch(self): str(self.case.attributegroup.uid): [new_attr.id], } } + request = lambda payload={}: type( + "rq", + (object,), + {"data": payload, "user": self.case.user} + ) + + valid_rq_init_date = File.objects.get(id=self.case.file_.id).update_date, - res, code = self._patch_file(self.case.file_.id, request_data) - invalid_res, invalid_code = self._patch_file(self.case.file_.id, {"status": 123}) + self.assertIsNone(File.objects.get(id=self.case.file_.id).validator) + + res, code = self._patch_file(self.case.file_.id, request(request_data)) + invalid_res, invalid_code = self._patch_file(self.case.file_.id, request({"status": 123})) self.assertEqual(invalid_code, 400) self.assertFalse(invalid_res["ok"]) @@ -180,6 +192,11 @@ def test_patch(self): File.objects.get(id=self.case.file_.id).status, "a" ) + self.assertNotEqual( + File.objects.get(id=self.case.file_.id).update_date, + valid_rq_init_date + ) + self.assertIsNotNone(File.objects.get(id=self.case.file_.id).validator) def test_form_query(self): query = {} @@ -200,10 +217,10 @@ def test_form_query(self): query["author"] = self.admin self._form_query_mixin(query) - query["from_"] = dt.now().strftime("%Y-%m-%d") + query["from_"] = dt.now().strftime(self.date_format) self._form_query_mixin(query) - query["to"] = (dt.now() + td(days=1)).strftime("%Y-%m-%d") + query["to"] = (dt.now() + td(days=1)).strftime(self.date_format) self._form_query_mixin(query) def _form_query_mixin(self, data={}): @@ -257,6 +274,16 @@ def _get_query( if author: query.set("author[]", author) filter["author__in"] = author + if from_: + query.set("from", from_) + filter["upload_date__gte"] = tz.make_aware( + dt.strptime(from_, self.date_format) + ) + if to: + query.set("to", to) + filter["upload_date__lte"] = tz.make_aware( + dt.strptime(to, self.date_format) + ) return query, filter diff --git a/backend-app/file/models.py b/backend-app/file/models.py index bdd67fe..bb55055 100644 --- a/backend-app/file/models.py +++ b/backend-app/file/models.py @@ -18,11 +18,18 @@ class File(Model): file_name: CharField = CharField(max_length=255) file_type: CharField = CharField(max_length=10) status: CharField = CharField(max_length=1, choices=STATUSES, default='v') - upload_date: DateTimeField = DateTimeField(auto_now_add=True) is_downloaded: BooleanField = BooleanField(default=False) + upload_date: DateTimeField = DateTimeField(auto_now_add=True) + update_date: DateTimeField = DateTimeField(auto_now_add=True, null=True) project: ForeignKey = ForeignKey("project.Project", on_delete=DO_NOTHING) author: ForeignKey = ForeignKey("user.CustomUser", on_delete=DO_NOTHING) + validator: ForeignKey = ForeignKey( + "user.CustomUser", + null=True, + on_delete=DO_NOTHING, + related_name="validator" + ) class Meta: db_table = "file" diff --git a/backend-app/file/serializers.py b/backend-app/file/serializers.py index 4bcf251..b3583e0 100644 --- a/backend-app/file/serializers.py +++ b/backend-app/file/serializers.py @@ -1,27 +1,40 @@ from rest_framework.serializers import SerializerMethodField, ModelSerializer from typing import Any +from django.utils import timezone from collections import OrderedDict from attribute.serializers import AttributeGroupSerializer from .models import File +from user.models import CustomUser +from typing import Optional class FileSerializer(ModelSerializer): attributes: SerializerMethodField = SerializerMethodField() author_name: SerializerMethodField = SerializerMethodField() + validator_name: SerializerMethodField = SerializerMethodField() class Meta: model = File - exclude = ("project", "author") + exclude = ("project", "author", "validator") def get_attributes(self, instance: File) -> list[OrderedDict[str, Any]]: return AttributeGroupSerializer(instance.attributegroup_set.all(), many=True).data def get_author_name(self, instance: File) -> str: return instance.author.username + def get_validator_name(self, instance: File) -> str: + validator: Optional[CustomUser] = instance.validator + return validator and validator.username + def update_file(self) -> File: - super().save() + self.validated_data["update_date"] = timezone.now() + self.validated_data["validator"] = self.context.get("validator") + + super().update(self.instance, self.validated_data) - self.instance.update_attributes(self.initial_data.get("attribute", {})) + self.instance.update_attributes( + self.initial_data.get("attribute", {}), + ) return self.instance diff --git a/backend-app/file/services.py b/backend-app/file/services.py index b92a967..ff0ea62 100644 --- a/backend-app/file/services.py +++ b/backend-app/file/services.py @@ -38,7 +38,7 @@ class ViewSetServices: def _patch_file( self, file_id: str, - request_data: dict[str, Any] + request: Request ) -> tuple[dict[str, Any], int]: # TODO: no handler for nofile case. imp tests after file = File.objects \ @@ -46,7 +46,12 @@ def _patch_file( .prefetch_related("attributegroup_set") \ .get(id=file_id) - updated_file = FileSerializer(file, request_data, partial=True) + updated_file = FileSerializer( + file, + request.data, + partial=True, + context={"validator": request.user} + ) update_valid = updated_file.is_valid() if update_valid: updated_file.update_file() diff --git a/backend-app/file/views.py b/backend-app/file/views.py index bc53069..7b1aa99 100644 --- a/backend-app/file/views.py +++ b/backend-app/file/views.py @@ -11,7 +11,7 @@ class FileViewSet(APIView, ViewSetServices): permission_classes = (IsAuthenticated, FilePermission) def patch(self, request: Request, fileID: str) -> Response: - response, status = self._patch_file(fileID, request.data) + response, status = self._patch_file(fileID, request) return Response(response, status=status) def delete(self, _, fileID: str) -> Response: diff --git a/frontend-app/src/App.jsx b/frontend-app/src/App.jsx index 74da484..0a50793 100644 --- a/frontend-app/src/App.jsx +++ b/frontend-app/src/App.jsx @@ -1,6 +1,6 @@ import { UserContext } from "./context/User"; import { AlertContext } from "./context/Alert"; -import { useState, useEffect } from "react"; +import { ReactElement, useState, useEffect } from "react"; import { useUser, useAlerts } from "./hooks"; import { api } from "./config/api"; import AppRouter from "./components/AppRouter"; @@ -9,6 +9,7 @@ import StatusLoad from "./components/ui/StatusLoad"; import AlertManager from "./components/AlertManager"; import './app.css'; +/** @returns {ReactElement} */ export default function App() { const { user, initUser } = useUser(); const alertManager = useAlerts(); diff --git a/frontend-app/src/components/common/FileModification/component.test.js b/frontend-app/src/components/common/FileModification/component.test.js index ee54a93..344724c 100644 --- a/frontend-app/src/components/common/FileModification/component.test.js +++ b/frontend-app/src/components/common/FileModification/component.test.js @@ -5,6 +5,7 @@ import { MemoryRouter } from "react-router-dom"; import { api } from '../../../config/api'; import { useFiles, useSwiper } from '../../../hooks'; import { raw_files, prepared_attributes } from '../../../config/mock_data'; +import { deepCopy } from '../../../utils'; jest.mock("../../../config/api"); afterEach(() => { @@ -24,17 +25,21 @@ test("file info component test", async () => { ; - api.request.mockResolvedValue({}); - act(() => { - filesHook.current.initFiles(raw_files); - swiperHook.current.setMax(raw_files.length); - }); + var files = deepCopy(raw_files); + files[0].status = "v"; + + api.request.mockResolvedValue({}); + act(() => { + filesHook.current.initFiles(files); + swiperHook.current.setMax(files.length); + }); - const { rerender } = render(component()); + const { rerender, container } = render(component()); expect(swiperHook.current.slide).toBe(0); expect(screen.getByRole('heading').innerHTML) .toBe(filesHook.current.files[swiperHook.current.slide].file_name); + expect(container.querySelector(".iss__fileInfo__validator")).toBeNull(); expect(screen.getByRole('button', { name: /Decline/})).not.toBeNull(); expect(screen.getByRole('button', { name: /Accept/})).not.toBeNull(); @@ -47,6 +52,7 @@ test("file info component test", async () => { expect(filesHook.current.files[swiperHook.current.slide - 1].status).toBe('d'); rerender(component()); + expect(container.querySelector(".iss__fileInfo__validator")).not.toBeNull(); expect(screen.getByRole('heading').innerHTML) .toBe(filesHook.current.files[swiperHook.current.slide].file_name); }); diff --git a/frontend-app/src/components/common/FileModification/index.jsx b/frontend-app/src/components/common/FileModification/index.jsx index 2c842c4..0d1b41a 100644 --- a/frontend-app/src/components/common/FileModification/index.jsx +++ b/frontend-app/src/components/common/FileModification/index.jsx @@ -83,11 +83,17 @@ export default function FileModification({ fileManager, sliderManager, attribute return (

{file.file_name}

+ { + file.status !== "v" && +
+ validated by: {file.validator_name} + {file.update_date} +
+ } { diff --git a/frontend-app/src/components/common/FileModification/styles.css b/frontend-app/src/components/common/FileModification/styles.css index df80aae..6fe7cf7 100644 --- a/frontend-app/src/components/common/FileModification/styles.css +++ b/frontend-app/src/components/common/FileModification/styles.css @@ -1,48 +1,59 @@ .iss__fileInfo { - max-width: 20%; - width: 100%; - display: flex; - flex-direction: column; - padding: 10px; - border: 1px solid var(--issBrand); + max-width: 20%; + width: 100%; + display: flex; + flex-direction: column; + padding: 10px; + border: 1px solid var(--issBrand); } .iss__fileInfo__title { - color: var(--issBrand); - margin-bottom: 20px; - word-break: break-all; + color: var(--issBrand); + margin-bottom: 20px; + word-break: break-all; } .iss__fileInfo__buttonsWrap { - display: flex; - gap: 12px; - width: fit-content; - place-self: center; - margin-top: auto; + display: flex; + gap: 12px; + width: fit-content; + place-self: center; + margin-top: auto; } .iss__fileInfo__buttonsWrap button { - padding: 4px 8px; - font-size: 14px; - cursor: pointer; - transition: 0.3s ease; - user-select: none; - outline: none; + padding: 4px 8px; + font-size: 14px; + cursor: pointer; + transition: 0.3s ease; + user-select: none; + outline: none; } .button--reject { - background-color: rgb(255, 98, 98, 0.2); - border: 1px solid var(--issRed); - color: var(--issRed); + background-color: rgb(255, 98, 98, 0.2); + border: 1px solid var(--issRed); + color: var(--issRed); } .button--accept { - background-color: rgb(153, 255, 98, 0.2); - border: 1px solid var(--issGreen); - color: var(--issGreen); + background-color: rgb(153, 255, 98, 0.2); + border: 1px solid var(--issGreen); + color: var(--issGreen); } .button--block { - filter: grayscale(1); - scale: 0.9; - pointer-events: none; -} -@media (hover:hover) { - .iss__fileInfo__buttonsWrap button:hover { - scale: 1.1; - } -} \ No newline at end of file + filter: grayscale(1); + scale: 0.9; + pointer-events: none; +} +.iss__fileInfo__validator { + display: flex; + flex-direction: column; + margin-bottom: 20px; +} +.iss__fileInfo__validator > span:nth-of-type(1) { + font-size: 12px; +} +.iss__fileInfo__validator > span:nth-of-type(2) { + font-size: 8px; +} +@media (hover: hover) { + .iss__fileInfo__buttonsWrap button:hover { + scale: 1.1; + } +} diff --git a/frontend-app/src/components/common/SelectorWrap/component.test.js b/frontend-app/src/components/common/SelectorWrap/component.test.js index 257cb52..2ba47a9 100644 --- a/frontend-app/src/components/common/SelectorWrap/component.test.js +++ b/frontend-app/src/components/common/SelectorWrap/component.test.js @@ -2,6 +2,11 @@ import { render, fireEvent, screen } from '@testing-library/react'; import SelectorWrap from '.'; import { prepared_attributes } from '../../../config/mock_data'; +/** +* @param {object} attribute +* @param {number} [choicePos=1] +* @returns {void} +*/ function test_solo_selector(attribute, choicePos = 1) { var selector = screen.queryByRole('combobox', { name: attribute.name }); diff --git a/frontend-app/src/components/common/TableBodySet/component.test.js b/frontend-app/src/components/common/TableBodySet/component.test.js index 5d6a8f5..8ba6c67 100644 --- a/frontend-app/src/components/common/TableBodySet/component.test.js +++ b/frontend-app/src/components/common/TableBodySet/component.test.js @@ -2,6 +2,15 @@ import { render, screen, fireEvent, getByTestId } from '@testing-library/react'; import TableBodySet from '.'; import { prepared_attribute_stats } from '../../../config/mock_data'; +/** +* @param {object} param0 +* @param {string} param0.name +* @param {string} param0.levelName +* @param {number} param0.v +* @param {number} param0.a +* @param {number} param0.d +* @returns {string} +*/ function formRowName({name, levelName, v, a, d}) { var val = `images: ${v?.image || 0} videos: ${v?.video || 0}`; var acc = `images: ${a?.image || 0} videos: ${a?.video || 0}`; @@ -9,6 +18,12 @@ function formRowName({name, levelName, v, a, d}) { return `${name} ${levelName} ${val} ${acc} ${dec} ${countItem(v, a, d)}`; } +/** +* @param {number} a +* @param {number} b +* @param {number} c +* @returns {number} +*/ const countItem = (a, b, c) => { var acc = (a?.image || 0) + (a?.video || 0); var dec = (b?.image || 0) + (b?.video || 0); @@ -16,9 +31,15 @@ const countItem = (a, b, c) => { return acc + dec + val; }; +/** @returns {Function} */ function tester() { var counter = 0; + /** + * @param {Array} rows + * @param {number} [depth=0] + * @returns {void} + */ function testRows(rows, depth=0) { counter += rows.length; diff --git a/frontend-app/src/components/common/TitleSwitch/component.test.js b/frontend-app/src/components/common/TitleSwitch/component.test.js index c683433..c337d1d 100644 --- a/frontend-app/src/components/common/TitleSwitch/component.test.js +++ b/frontend-app/src/components/common/TitleSwitch/component.test.js @@ -2,6 +2,13 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import TitleSwitch from '.'; +/** +* @param {Array} options +* @param {string} current +* @param {string} parentLink +* @param {object} titleLink +* @returns {void} +*/ function test_selected_option(options, current, parentLink, titleLink) { var title = screen.getByRole('heading'); var [titleChild] = title.children; diff --git a/frontend-app/src/components/forms/SelectorGroup/styles.css b/frontend-app/src/components/forms/SelectorGroup/styles.css index 30a846d..2c40704 100644 --- a/frontend-app/src/components/forms/SelectorGroup/styles.css +++ b/frontend-app/src/components/forms/SelectorGroup/styles.css @@ -56,7 +56,6 @@ border: 1px solid var(--issGreen); color: var(--issGreen); width: 100%; - margin-bottom: 12px; } .del--group { diff --git a/frontend-app/src/components/ui/DateSelector/component.test.js b/frontend-app/src/components/ui/DateSelector/component.test.js index 87d1c5a..72261f8 100644 --- a/frontend-app/src/components/ui/DateSelector/component.test.js +++ b/frontend-app/src/components/ui/DateSelector/component.test.js @@ -5,6 +5,8 @@ test("date selector test", () => { const containerClass = ".iss__dateSelector__options"; const parentClass = ".iss__dateSelector__selected"; + var change = null; + const toggler = () => { var opened = false; return (set) => { @@ -19,19 +21,41 @@ test("date selector test", () => { }; const { container } = render( - e} defaultSelected={{}} /> + change = e} defaultSelected={{}} /> ); var toggle = toggler(); - screen.getByText("clear dates"); expect(toggle()).toBeTruthy(); expect(toggle()).toBeFalsy(); expect(toggle()).toBeTruthy(); expect(container.querySelector(".off--title")).not.toBeNull(); + fireEvent.change(container.querySelectorAll("input")[0], { target: {value: "2000-01-01"}}); fireEvent.click(screen.getByRole("button")); toggle(true); expect(container.querySelector(containerClass).className).toBe(containerClass.slice(1)); + expect(container.querySelector(".off--title")).toBeNull(); + screen.getByText("2000-01-01 - ..."); + + fireEvent.change(container.querySelectorAll("input")[1], { target: {value: "2000-01-01"}}); + fireEvent.click(screen.getByRole("button")); + toggle(true); + screen.getByText("2000-01-01 - ..."); + + fireEvent.change(container.querySelectorAll("input")[1], { target: { value: "2001-01-01"}}); + fireEvent.click(screen.getByRole("button")); + toggle(true); + screen.getByText("2000-01-01 - 2001-01-01"); + + expect(change).toEqual({ from: "2000-01-01", to: "2001-01-01" }); +}); + +test("date selector with default", () => { + render( + e} defaultSelected={{ from: "2000-01-01", to: "2001-01-01" }} /> + ); + + screen.getByText("2000-01-01 - 2001-01-01"); });