Skip to content

Commit f8a86e7

Browse files
committed
feat: export observations from Observations list
1 parent c2a18e6 commit f8a86e7

File tree

6 files changed

+192
-14
lines changed

6 files changed

+192
-14
lines changed

backend/application/commons/services/export.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
logger = logging.getLogger("secobserve.commons")
1414

15+
1516
def export_excel(objects: QuerySet, title: str, excludes: list[str], foreign_keys: list[str]) -> Workbook:
1617
workbook = Workbook()
1718
workbook.iso_dates = True
@@ -45,7 +46,7 @@ def export_excel(objects: QuerySet, title: str, excludes: list[str], foreign_key
4546
try:
4647
worksheet.cell(row=row_num, column=col_num, value=value)
4748
except Exception as e:
48-
logger.warning(f"Cannot set cell with type {type(value)}")
49+
logger.warning("Cannot set cell with type %s", type(value))
4950
logger.warning(str(e))
5051
col_num += 1
5152
row_num += 1

backend/application/core/api/views.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@
133133
)
134134
from application.core.services.export_observations import (
135135
export_observations_csv,
136+
export_observations_csv_for_product,
136137
export_observations_excel,
138+
export_observations_excel_for_product,
137139
)
138140
from application.core.services.observations_bulk_actions import (
139141
observation_logs_bulk_approval,
@@ -230,7 +232,7 @@ def export_observations_excel(self, request: Request, pk: int) -> HttpResponse:
230232
if status and (status, status) not in Status.STATUS_CHOICES:
231233
raise ValidationError(f"Status {status} is not a valid choice")
232234

233-
workbook = export_observations_excel(product, statuses)
235+
workbook = export_observations_excel_for_product(product, statuses)
234236

235237
with NamedTemporaryFile() as tmp:
236238
workbook.save(tmp.name) # nosemgrep: python.lang.correctness.tempfile.flush.tempfile-without-flush
@@ -265,7 +267,7 @@ def export_observations_csv(self, request: Request, pk: int) -> HttpResponse:
265267
response = HttpResponse(content_type="text/csv")
266268
response["Content-Disposition"] = "attachment; filename=observations.csv"
267269

268-
export_observations_csv(response, product, statuses)
270+
export_observations_csv_for_product(response, product, statuses)
269271

270272
return response
271273

@@ -678,6 +680,47 @@ def count_reviews(self, request: Request) -> Response:
678680
count = get_observations().filter(current_status=Status.STATUS_IN_REVIEW).count()
679681
return Response(status=HTTP_200_OK, data={"count": count})
680682

683+
@extend_schema(
684+
methods=["GET"],
685+
responses={200: None},
686+
)
687+
@action(detail=False, methods=["get"])
688+
def export_excel(self, request: Request) -> HttpResponse:
689+
queryset = self._filter_queryset(request)
690+
workbook = export_observations_excel(queryset)
691+
692+
with NamedTemporaryFile() as tmp:
693+
workbook.save(tmp.name) # nosemgrep: python.lang.correctness.tempfile.flush.tempfile-without-flush
694+
# export works fine without .flush()
695+
tmp.seek(0)
696+
stream = tmp.read()
697+
698+
response = HttpResponse(
699+
content=stream,
700+
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
701+
)
702+
response["Content-Disposition"] = "attachment; filename=observations.xlsx"
703+
return response
704+
705+
@extend_schema(
706+
methods=["GET"],
707+
responses={200: None},
708+
parameters=[],
709+
)
710+
@action(detail=False, methods=["get"])
711+
def export_csv(self, request: Request) -> HttpResponse:
712+
queryset = self._filter_queryset(request)
713+
response = HttpResponse(content_type="text/csv")
714+
response["Content-Disposition"] = "attachment; filename=observations.csv"
715+
export_observations_csv(response, queryset)
716+
return response
717+
718+
def _filter_queryset(self, request: Request) -> QuerySet:
719+
queryset = self.get_queryset()
720+
for backend in self.filter_backends:
721+
queryset = backend().filter_queryset(request, queryset, self)
722+
return queryset
723+
681724

682725
class ObservationTitleViewSet(GenericViewSet, ListModelMixin, RetrieveModelMixin):
683726
serializer_class = ObservationTitleSerializer

backend/application/core/services/export_observations.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,22 @@
88
from application.core.models import Observation, Product
99

1010

11-
def export_observations_excel(product: Product, status: Optional[list[str]]) -> Workbook:
12-
observations = _get_observations(product, status)
11+
def export_observations_excel(observations: QuerySet) -> Workbook:
1312
return export_excel(observations, "Observations", _get_excludes(), _get_foreign_keys())
1413

1514

16-
def export_observations_csv(response: HttpResponse, product: Product, status: Optional[list[str]]) -> None:
15+
def export_observations_excel_for_product(product: Product, status: Optional[list[str]]) -> Workbook:
16+
observations = _get_observations(product, status)
17+
return export_observations_excel(observations)
18+
19+
20+
def export_observations_csv(response: HttpResponse, observations: QuerySet) -> None:
21+
export_csv(response, observations, _get_excludes(), _get_foreign_keys())
22+
23+
24+
def export_observations_csv_for_product(response: HttpResponse, product: Product, status: Optional[list[str]]) -> None:
1725
observations = _get_observations(product, status)
18-
export_csv(
19-
response,
20-
observations,
21-
_get_excludes(),
22-
_get_foreign_keys(),
23-
)
26+
export_observations_csv(response, observations)
2427

2528

2629
def _get_observations(product: Product, status: Optional[list[str]]) -> QuerySet:
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { faFileCsv, faFileExcel } from "@fortawesome/free-solid-svg-icons";
2+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3+
import DownloadIcon from "@mui/icons-material/Download";
4+
import { ListItemIcon } from "@mui/material";
5+
import Button from "@mui/material/Button";
6+
import Menu from "@mui/material/Menu";
7+
import MenuItem from "@mui/material/MenuItem";
8+
import queryString from "query-string";
9+
import { Fragment, MouseEvent, useState } from "react";
10+
import { useListContext, useNotify } from "react-admin";
11+
12+
import axios_instance from "../../access_control/auth_provider/axios_instance";
13+
import { getIconAndFontColor } from "../../commons/functions";
14+
15+
const ExportMenu = () => {
16+
const notify = useNotify();
17+
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
18+
const open = Boolean(anchorEl);
19+
const { filterValues, sort } = useListContext();
20+
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
21+
setAnchorEl(event.currentTarget);
22+
};
23+
const handleClose = () => {
24+
setAnchorEl(null);
25+
};
26+
27+
const exportDataCsv = async (url: string, filename: string, message: string) => {
28+
axios_instance
29+
.get(url)
30+
.then(function (response) {
31+
const blob = new Blob([response.data], { type: "text/csv" });
32+
const url = window.URL.createObjectURL(blob);
33+
const link = document.createElement("a");
34+
link.href = url;
35+
link.download = filename;
36+
link.click();
37+
38+
notify(message + " downloaded", {
39+
type: "success",
40+
});
41+
})
42+
.catch(function (error) {
43+
notify(error.message, {
44+
type: "warning",
45+
});
46+
});
47+
handleClose();
48+
};
49+
50+
const exportDataExcel = async (url: string, filename: string, message: string) => {
51+
axios_instance
52+
.get(url, {
53+
responseType: "arraybuffer",
54+
headers: { Accept: "*/*" },
55+
})
56+
.then(function (response) {
57+
const blob = new Blob([response.data], {
58+
type: "application/zip",
59+
});
60+
const url = window.URL.createObjectURL(blob);
61+
const link = document.createElement("a");
62+
link.href = url;
63+
link.download = filename;
64+
link.click();
65+
66+
notify(message + " downloaded", {
67+
type: "success",
68+
});
69+
})
70+
.catch(function (error) {
71+
notify(error.message, {
72+
type: "warning",
73+
});
74+
});
75+
handleClose();
76+
};
77+
78+
const exportObservationsExcel = async () => {
79+
exportDataExcel("/observations/export_excel/?" + queryParams(), "open_observations.xlsx", "Observations");
80+
};
81+
82+
const exportObservationsCsv = async () => {
83+
exportDataCsv("/observations/export_csv/?" + queryParams(), "open_observations.csv", "Observations");
84+
};
85+
86+
const queryParams = () => {
87+
const query = { ...filterValues, ...sort };
88+
return queryString.stringify(query);
89+
};
90+
91+
return (
92+
<Fragment>
93+
<Button
94+
id="export-button"
95+
aria-controls={open ? "export-menu" : undefined}
96+
aria-haspopup="true"
97+
aria-expanded={open ? "true" : undefined}
98+
onClick={handleClick}
99+
size="small"
100+
sx={{ paddingTop: 0, paddingBottom: 0, paddingLeft: "5px", paddingRight: "5px" }}
101+
startIcon={<DownloadIcon />}
102+
>
103+
Export
104+
</Button>
105+
<Menu
106+
id="basic-menu"
107+
anchorEl={anchorEl}
108+
open={open}
109+
onClose={handleClose}
110+
MenuListProps={{
111+
"aria-labelledby": "basic-button",
112+
}}
113+
>
114+
<MenuItem onClick={exportObservationsExcel}>
115+
<ListItemIcon>
116+
<FontAwesomeIcon icon={faFileExcel} color={getIconAndFontColor()} />
117+
</ListItemIcon>
118+
Observations / Excel
119+
</MenuItem>
120+
<MenuItem onClick={exportObservationsCsv}>
121+
<ListItemIcon>
122+
<FontAwesomeIcon icon={faFileCsv} color={getIconAndFontColor()} />
123+
</ListItemIcon>
124+
Observations / CSV
125+
</MenuItem>
126+
</Menu>
127+
</Fragment>
128+
);
129+
};
130+
131+
export default ExportMenu;

frontend/src/core/observations/ObservationList.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
Observation,
3333
PURL_TYPE_CHOICES,
3434
} from "../types";
35+
import ExportMenu from "./ExportMenu";
3536
import ObservationBulkAssessment from "./ObservationBulkAssessment";
3637
import ObservationExpand from "./ObservationExpand";
3738
import { IDENTIFIER_OBSERVATION_LIST, setListIdentifier } from "./functions";
@@ -91,6 +92,7 @@ function listFilters() {
9192

9293
const ListActions = () => (
9394
<TopToolbar>
95+
<ExportMenu />
9496
<FilterButton />
9597
</TopToolbar>
9698
);

frontend/src/rules/functions.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,6 @@ export const validateRuleForm = (values: any) => {
9595
}
9696
}
9797

98-
console.log(errors);
99-
10098
return errors;
10199
};
102100

0 commit comments

Comments
 (0)