Skip to content

Commit f3fe3c8

Browse files
add attribute diff stats tab
1 parent e377f7a commit f3fe3c8

File tree

13 files changed

+312
-82
lines changed

13 files changed

+312
-82
lines changed

backend-app/attribute/models.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@
1010
BooleanField,
1111
TextField,
1212
CASCADE,
13-
IntegerField
13+
IntegerField,
14+
DateTimeField,
1415
)
1516
from tree_queries.models import TreeNode
1617
from uuid import uuid4
1718

1819

1920
class Attribute(TreeNode):
2021
name = CharField(max_length=255)
22+
create_date = DateTimeField(auto_now_add=True)
2123

2224
project = ForeignKey("project.Project", on_delete=DO_NOTHING)
2325
level = ForeignKey("attribute.Level", on_delete=DO_NOTHING)

backend-app/attribute/services.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
)
88
from rest_framework.views import Request, Response, APIView
99
from django.db import transaction, IntegrityError
10-
from typing import Any
10+
from typing import Any, Optional
1111
from file.models import File
1212
from .models import Level, Attribute
1313

@@ -105,3 +105,27 @@ def _check_intersection(self, item: Level | Attribute) -> bool:
105105
else: delete_ids.add(item.id)
106106

107107
return bool(file_attributes.intersection(delete_ids))
108+
109+
110+
def _attribute_diff(
111+
project_id: int,
112+
diff_from: Optional[str]
113+
) -> tuple[list[tuple[int, str, str, int]], int]:
114+
if not diff_from: return [], HTTP_200_OK
115+
116+
base_query = Attribute.objects \
117+
.prefetch_related("level") \
118+
.filter(project_id=project_id) \
119+
.values_list("id", "name", "level__name")
120+
121+
attr_current = set(base_query)
122+
attr_upto = set(base_query.filter(create_date__lt=diff_from))
123+
124+
into_response = lambda lst, tp: [(r[0], r[1], r[2], tp) for r in lst]
125+
126+
return (
127+
into_response(attr_current - attr_upto, 1)
128+
+
129+
into_response(attr_upto - attr_current, 0),
130+
HTTP_200_OK
131+
)

backend-app/attribute/urls.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from django.urls import path
2-
from .views import LevelsViewSet, AttributesViewSet
2+
from .views import LevelsViewSet, AttributesViewSet, attribute_diff
33

44
urlpatterns = (
55
path("levels/", LevelsViewSet.as_view()),
66
path("levels/<int:item_id>/", LevelsViewSet.as_view()),
77
path("attributes/", AttributesViewSet.as_view()),
8+
path("attributes/diff/<int:projectID>/", attribute_diff),
89
path("attributes/<int:item_id>/", AttributesViewSet.as_view()),
910
)

backend-app/attribute/views.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
from rest_framework.views import Response, Request
12
from rest_framework.permissions import IsAuthenticated
3+
from rest_framework.decorators import api_view, permission_classes
4+
from project.permissions import ProjectStatsPermission
5+
from typing import cast, Optional
26
from .permissions import LevelPermission, AttributePermission
37
from .serializers import Level, Attribute
4-
from .services import ViewMixIn
8+
from .services import ViewMixIn, _attribute_diff
59

610

711
class LevelsViewSet(ViewMixIn):
@@ -12,3 +16,11 @@ class LevelsViewSet(ViewMixIn):
1216
class AttributesViewSet(ViewMixIn):
1317
permission_classes = (IsAuthenticated, AttributePermission)
1418
model = Attribute
19+
20+
21+
@api_view(("GET",))
22+
@permission_classes((IsAuthenticated, ProjectStatsPermission))
23+
def attribute_diff(request: Request, projectID: int) -> Response:
24+
param = cast(Optional[str], request.query_params.get("diff_from"))
25+
response, status = _attribute_diff(projectID, param)
26+
return Response(response, status=status)

backend-app/file/export.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def into_response(self) -> BytesIO:
8080
file = BytesIO()
8181

8282
match self._type:
83-
case "attribute":
83+
case "attribute" | "diff":
8484
headers = self.ATTRIBUTE_HEADERS
8585
write = self._write_attribute
8686
case "user":

backend-app/file/services.py

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
HTTP_201_CREATED,
55
HTTP_200_OK,
66
HTTP_202_ACCEPTED,
7-
HTTP_404_NOT_FOUND
7+
HTTP_404_NOT_FOUND,
88
)
99
from django.db.models import (
1010
Count,
@@ -24,7 +24,7 @@
2424
from attribute.models import Level, AttributeGroup, Attribute
2525
from user.models import CustomUser
2626
from json import loads
27-
from typing import Any
27+
from typing import Any, Optional
2828
from datetime import datetime as dt
2929
from io import BytesIO
3030
from .export import IMPLEMENTED, JSON, CSV, XLS
@@ -295,7 +295,105 @@ class StatsServices:
295295
)
296296

297297
@classmethod
298-
def from_attribute(cls, project_id: int) -> tuple[list[dict[str, Any]], int]:
298+
def from_diff(
299+
cls,
300+
project_id: int,
301+
diff_from: Optional[str]
302+
) -> tuple[list[dict[str, Any]], int]:
303+
# TODO: refactor, unify, if this shows legit data
304+
if not diff_from: return [], HTTP_200_OK
305+
306+
def helper(date: Optional[str]) -> list[tuple[Any, ...]]:
307+
nonlocal project_id
308+
query = """
309+
select F.status, F.file_type, count(F.file_type) as count, L.order, L.name, A.id, A.name, A.parent_id, A.payload
310+
from file as F
311+
left join attribute_group AG on F.id = AG.file_id
312+
left join attribute_group_attribute as AGA on AGA.attributegroup_id = AG.uid
313+
left join attribute A on AGA.attribute_id = A.id
314+
left join level as L on L.id = A.level_id
315+
where F.project_id = {} {}
316+
group by A.id, F.file_type, F.status, L.id;
317+
""".format(project_id, f"and update_date < '{date}'" * bool(date))
318+
319+
with connection.cursor() as cur:
320+
cur.execute(query, [])
321+
return cur.fetchall()
322+
323+
stats_upto = helper(diff_from)
324+
stats_current = helper(None)
325+
326+
intermediate = {}
327+
328+
for row in stats_current:
329+
a_st, a_tp, cnt, order, l_name, a_id, a_name, a_par, a_payl = row
330+
331+
a_st = a_st or "v"
332+
a_name = a_name or "no attribute"
333+
a_tp = a_tp or "no type"
334+
order = order or 0
335+
l_name = l_name or "no level"
336+
a_id = a_id or "no id"
337+
338+
target = intermediate.get(a_id)
339+
340+
if not target: intermediate[a_id] = {
341+
"id": a_id,
342+
"levelName": l_name,
343+
"name": a_name,
344+
"parent": a_par,
345+
"payload": a_payl,
346+
"order": order,
347+
a_st: {a_tp: cnt}
348+
}
349+
elif target.get(a_st): target[a_st][a_tp] = target[a_st].get(a_tp, 0) + cnt
350+
else: target[a_st] = {a_tp: cnt}
351+
352+
for row in stats_upto:
353+
a_st, a_tp, cnt, order, l_name, a_id, a_name, a_par, a_payl = row
354+
355+
a_st = a_st or "v"
356+
a_name = a_name or "no attribute"
357+
a_tp = a_tp or "no type"
358+
order = order or 0
359+
l_name = l_name or "no level"
360+
a_id = a_id or "no id"
361+
362+
target = intermediate.get(a_id)
363+
364+
if not target: intermediate[a_id] = {
365+
"id": a_id,
366+
"levelName": l_name,
367+
"name": a_name,
368+
"parent": a_par,
369+
"payload": a_payl,
370+
"order": order,
371+
a_st: {a_tp: -cnt}
372+
}
373+
elif target.get(a_st): target[a_st][a_tp] = target[a_st].get(a_tp, 0) - cnt
374+
else: target[a_st] = {a_tp: -cnt}
375+
376+
items = list(intermediate.items())
377+
for key, row in items:
378+
if not bool(
379+
row.get("v", {}).get("image", 0)
380+
| row.get("v", {}).get("video", 0)
381+
| row.get("a", {}).get("image", 0)
382+
| row.get("a", {}).get("video", 0)
383+
| row.get("d", {}).get("image", 0)
384+
| row.get("d", {}).get("video", 0)
385+
):
386+
del intermediate[key]
387+
continue
388+
389+
if parent := next((p for p in intermediate.values() if p["id"] == row["parent"]), None):
390+
parent["children"] = parent.get("children", []) + [row]
391+
del intermediate[key]
392+
393+
return sorted(intermediate.values(), key=lambda r: r["order"]), HTTP_200_OK
394+
395+
@classmethod
396+
def from_attribute(cls, project_id: int, *args) -> tuple[list[dict[str, Any]], int]:
299397
stats: list[dict[str, Any]] = list(
300398
Level.objects
301399
.filter(project_id=project_id)
@@ -345,7 +443,7 @@ def from_attribute(cls, project_id: int) -> tuple[list[dict[str, Any]], int]:
345443
return cls._attribute_stat_adapt(stats), HTTP_200_OK
346444

347445
@classmethod
348-
def from_user(cls, project_id: int):
446+
def from_user(cls, project_id: int, *args):
349447
stats: list[dict[str, Any]] = list(
350448
File.objects
351449
.filter(project_id=project_id)
@@ -427,7 +525,8 @@ def form_export_file(query: dict[str, Any]) -> BytesIO:
427525
query_set = {"type", "project_id", "choice"}
428526
choice_map = {
429527
"attribute": StatsServices.from_attribute,
430-
"user": StatsServices.from_user
528+
"user": StatsServices.from_user,
529+
"diff": StatsServices.from_diff
431530
}
432531
export_map = {"json": JSON, "csv": CSV, "xlsx": XLS}
433532

@@ -438,11 +537,12 @@ def form_export_file(query: dict[str, Any]) -> BytesIO:
438537
choice = query["choice"]
439538
project_id = query["project_id"]
440539
file_type = query["type"]
540+
diff_from = query.get("diff_from")
441541

442542
assert file_type in IMPLEMENTED, f"{file_type} not implemented"
443-
assert choice in set(choice_map), f"export for {choice} is not implemented"
543+
assert (parser := choice_map.get(choice)), f"export for {choice} is not implemented"
444544

445-
stats, _ = choice_map[choice](project_id)
545+
stats, _ = parser(project_id, diff_from)
446546
file = export_map[file_type](stats, choice)
447547

448548
return file.into_response()

backend-app/file/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
get_user_stats,
66
get_attribute_stats,
77
export_stats,
8-
get_duplicates
8+
get_duplicates,
9+
get_diff_stats
910
)
1011

1112
urlpatterns: tuple = (
1213
path("project/<int:projectID>/", FilesViewSet.as_view()),
1314
path("stats/attribute/<int:projectID>/", get_attribute_stats),
1415
path("stats/user/<int:projectID>/", get_user_stats),
16+
path("stats/diff/<int:projectID>/", get_diff_stats),
1517
path("stats/export/", export_stats),
1618
path("duplicates/<str:fileID>/", get_duplicates),
1719
path("<str:fileID>/", FileViewSet.as_view()),

backend-app/file/views.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from rest_framework.permissions import IsAuthenticated
55
from project.permissions import ProjectStatsPermission
66
from django.http.response import FileResponse
7+
from typing import cast, Optional
78
from .permissions import FilePermission
89
from .services import (
910
ViewSetServices,
@@ -53,9 +54,20 @@ def get_user_stats(_, projectID: int) -> Response:
5354
return Response(response, status=status)
5455

5556

57+
@api_view(("GET",))
58+
@permission_classes((IsAuthenticated, ProjectStatsPermission))
59+
def get_diff_stats(request: Request, projectID: int) -> Response:
60+
param = cast(Optional[str], request.query_params.get("diff_from"))
61+
response, status = StatsServices.from_diff(projectID, param)
62+
return Response(response, status=status)
63+
64+
5665
@api_view(("GET",))
5766
@permission_classes((IsAuthenticated, ProjectStatsPermission))
5867
def export_stats(request: Request) -> Response | FileResponse:
68+
response = form_export_file(request.query_params)
69+
return FileResponse(response)
70+
5971
try:
6072
response = form_export_file(request.query_params)
6173
return FileResponse(response)

docker-compose.dev.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ services:
66
DEBUG: ${DEBUG:-true}
77
volumes:
88
- ./backend-app:/app
9+
command: >
10+
sh -c "gunicorn --config './conf/gunicorn_conf.py' proj_back.wsgi:application"
911
1012
iss-storage:
1113
environment:

frontend-app/src/components/common/FileStats/component.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ test("files stats component test", async () => {
2222
</Provider>
2323
));
2424

25-
screen.getByRole('table');
25+
expect(screen.getAllByRole('table')).toHaveLength(2);
2626
expect(screen.queryByTestId('load-c')).toBeNull();
2727
expect(screen.getAllByRole("radio")[0].checked).toBeTruthy();
2828
expect(screen.getAllByRole("radio")[1].checked).toBeFalsy();

0 commit comments

Comments
 (0)