Coverage for rest_api/viewsets.py: 29%
472 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-10 03:22 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-10 03:22 +0000
1from datetime import datetime
2from datetime import timezone
3from functools import reduce
4import hashlib
5import io
6import json
7import operator
8import os
9import pickle
10import uuid
11import zipfile
13from django.core.cache import cache
14from django.core.files.uploadedfile import InMemoryUploadedFile
15from django.db.models import CharField
16from django.db.models import F
17from django.db.models import Q
18from django.db.models.functions import Cast
19from django.db.utils import IntegrityError
20from django.http import FileResponse
21from django_filters.rest_framework import DjangoFilterBackend
22from rest_framework import generics
23from rest_framework import status
24from rest_framework import viewsets
25from rest_framework.decorators import action
26from rest_framework.request import Request
27from rest_framework.response import Response
29from rest_api.data_entry.gbk_import import import_gbk_file
30from rest_api.data_entry.property_job import delete_property
31from rest_api.data_entry.property_job import find_or_create_property
32from rest_api.data_entry.reference_job import delete_reference
33from rest_api.data_entry.sample_entry_job import check_for_new_data
34from rest_api.management.commands.import_lineage import LineageImport
35from rest_api.utils import generate_job_ID
36from rest_api.utils import get_distinct_gene_symbols
37from rest_api.utils import parse_default_data
38from rest_api.utils import PropertyColumnMapping
39from rest_api.utils import strtobool
40from sonar_backend.settings import CACHE_OBJECT_TTL
41from sonar_backend.settings import LOGGER
42from sonar_backend.settings import SONAR_DATA_ENTRY_FOLDER
43from . import models
44from .serializers import AlignmentSerializer
45from .serializers import GeneSerializer
46from .serializers import ImportLogSerializer
47from .serializers import LineagesSerializer
48from .serializers import ProcessingJobSerializer
49from .serializers import PropertySerializer
50from .serializers import ReferenceSerializer
51from .serializers import RepliconSerializer
54class Echo:
55 """An object that implements just the write method of the file-like
56 interface.
57 """
59 def write(self, value):
60 """Write the value by returning it, instead of storing in a buffer."""
61 return value
64class AlignmentViewSet(
65 viewsets.GenericViewSet,
66 generics.mixins.ListModelMixin,
67 generics.mixins.RetrieveModelMixin,
68):
69 """
70 AlignmentViewSet
71 """
73 queryset = models.Alignment.objects.all()
74 serializer_class = AlignmentSerializer
76 @action(
77 detail=False,
78 methods=["get"],
79 url_path="get_alignment_data/(?P<seqhash>[a-zA-Z0-9]+)/(?P<replicon_id>[0-9]+)",
80 )
81 def get_alignment_data(self, request: Request, seqhash=None, replicon_id=None):
82 queryset = self.queryset.filter(
83 sequence__seqhash=seqhash,
84 replicon_id=replicon_id,
85 ).select_related("sequence")
87 sequence_data = list(
88 queryset.annotate(
89 alignment_id=F("id"),
90 sequence_name=F("sequence__name"),
91 ).values(
92 "replicon_id",
93 "alignment_id",
94 "sequence_id",
95 "sequence_name",
96 )
97 )
99 # Falls nur ein Datensatz zurückkommt, als einzelnes Dict liefern
100 if sequence_data:
101 return Response(sequence_data[0], status=status.HTTP_200_OK)
102 else:
103 return Response({}, status=status.HTTP_404_NOT_FOUND)
105 @action(detail=False, methods=["post"])
106 def get_bulk_alignment_data(self, request: Request, *args, **kwargs):
107 data = json.loads(request.body.decode("utf-8"))
108 sequence_data_list = data.get("sample_data", [])
110 # Extract values from the list
111 seqhash_values = [item.get("seqhash") for item in sequence_data_list]
112 replicon_id_values = [item.get("replicon_id") for item in sequence_data_list]
114 queryset = models.Alignment.objects.filter(
115 reduce(
116 operator.or_,
117 (
118 Q(sequence__seqhash=seqhash, replicon_id=replicon_id)
119 for seqhash, replicon_id in zip(seqhash_values, replicon_id_values)
120 ),
121 )
122 )
123 queryset = queryset.select_related("sequence")
125 # Convert the queryset to a list of dictionaries
126 # sequence_data = list(queryset.values())
127 sequence_data = list(
128 queryset.annotate(
129 alignment_id=F("id"),
130 sequence_name=F("sequence__name"),
131 ).values(
132 "replicon_id",
133 "alignment_id",
134 "sequence_id",
135 "sequence_name",
136 )
137 )
139 return Response(data=sequence_data, status=status.HTTP_200_OK)
142class RepliconViewSet(viewsets.ModelViewSet):
143 queryset = models.Replicon.objects.all()
144 serializer_class = RepliconSerializer
146 @action(detail=False, methods=["get"])
147 def distinct_accessions(self, request: Request, *args, **kwargs):
148 queryset = models.Replicon.objects.only("accession").values_list(
149 "accession", flat=True
150 )
151 if ref := request.query_params.get("reference"):
152 queryset = queryset.filter(reference__accession=ref)
153 distinct_accessions = queryset.distinct()
155 return Response(
156 {"accessions": distinct_accessions},
157 status=status.HTTP_200_OK,
158 )
160 @action(detail=False, methods=["get"])
161 def get_molecule_data(self, request: Request, *args, **kwargs):
162 sample_data = {}
164 if replicon_id := request.query_params.get("replicon_id"):
165 queryset_obj = self.queryset.filter(id=replicon_id)
166 elif ref := request.query_params.get("reference"):
167 queryset_obj = self.queryset.filter(reference__accession=ref)
168 else:
169 return Response(
170 {"detail": "Accession ID is missing"},
171 status=status.HTTP_400_BAD_REQUEST,
172 )
174 if queryset_obj.exists():
175 # NOTE: Fixed value for translation ID
176 # sample_data.translation_id = 1
177 # sample_data = serialize('json', queryset_obj)
178 sample_data = queryset_obj.values()
179 for obj in sample_data:
180 obj["translation_id"] = 1
181 return Response(data=sample_data, status=status.HTTP_200_OK)
184class GeneViewSet(viewsets.ModelViewSet):
185 queryset = models.Gene.objects.all()
186 serializer_class = GeneSerializer
188 @action(detail=False, methods=["get"])
189 def distinct_gene_symbols(self, request: Request, *args, **kwargs):
190 reference = request.query_params.get("reference")
191 gene_symbols = get_distinct_gene_symbols(reference=reference)
192 return Response(
193 {"gene_symbols": gene_symbols},
194 status=status.HTTP_200_OK,
195 )
197 @action(detail=False, methods=["get"])
198 def get_gene_data(self, request: Request):
199 sample_data = {}
201 if ref_acc := request.query_params.get("ref_acc"):
202 # queryset = models.Gene.objects.filter(replicon__reference__accession=ref_acc)
203 queryset = models.GeneSegment.objects.select_related(
204 "gene__replicon__reference"
205 ).filter(gene__replicon__reference__accession=ref_acc)
207 elif replicon_id := request.query_params.get("replicon_id"):
208 queryset = self.queryset.filter(replicon_id=replicon_id)
209 elif replicon_acc := request.query_params.get("replicon_acc"):
210 queryset = models.GeneSegment.objects.select_related(
211 "gene__replicon"
212 ).filter(gene__replicon__accession=replicon_acc)
213 else:
214 return Response(
215 {"detail": "Searchable field is missing"},
216 status=status.HTTP_400_BAD_REQUEST,
217 )
219 sample_data = []
220 # TODO : check for mulitple cds's per gene
221 # raise NotImplementedError("Multiple CDS per gene not implemented")
222 for item in queryset.all():
223 _data = {}
224 _data["reference.id"] = item.gene.replicon.reference.id
225 _data["reference.accession"] = item.gene.replicon.reference.accession
226 _data["reference.description"] = item.gene.replicon.reference.description
227 _data["reference.organism"] = item.gene.replicon.reference.organism
228 _data["reference.host"] = item.gene.replicon.reference.host
229 _data["replicon.id"] = item.gene.replicon.id
230 _data["replicon.accession"] = item.gene.replicon.accession
231 _data["replicon.description"] = item.gene.replicon.description
232 _data["replicon.length"] = item.gene.replicon.length
233 _data["replicon.segment_number"] = item.gene.replicon.segment_number
234 _data["gene.id"] = item.gene.id
235 _data["gene.start"] = item.gene.start
236 _data["gene.end"] = item.gene.end
237 _data["gene.description"] = item.gene.description
238 _data["gene.gene_symbol"] = item.gene.symbol
239 _data["gene.gene_accession"] = item.gene.accession
240 _data["gene.gene_sequence"] = item.gene.sequence
241 _data["gene_segment.id"] = item.id
242 _data["gene_segment.gene_id"] = item.gene_id
243 _data["gene_segment.start"] = item.start
244 _data["gene_segment.end"] = item.end
245 _data["gene_segment.forward_strand"] = item.forward_strand
246 _data["gene_segment.order"] = item.order
248 # Add CDS information
249 cds_list = []
250 for cds in item.gene.cds_set.all():
251 cds_data = {
252 "cds.id": cds.id,
253 "cds.accession": cds.accession,
254 "cds.sequence": cds.sequence,
255 "cds.description": cds.description,
256 }
257 cds_segments = []
258 for segment in cds.cds_segments.all():
259 segment_data = {
260 "cds_segment.id": segment.id,
261 "cds_segment.order": segment.order,
262 "cds_segment.start": segment.start,
263 "cds_segment.end": segment.end,
264 "cds_segment.forward_strand": segment.forward_strand,
265 }
266 cds_segments.append(segment_data)
267 cds_data["cds_segments"] = cds_segments
269 # Add Peptide information
270 peptide_list = []
271 for peptide in cds.peptides.all():
272 peptide_data = {
273 "peptide.id": peptide.id,
274 "peptide.description": peptide.description,
275 "peptide.type": peptide.type,
276 }
277 peptide_segments = []
278 for segment in peptide.peptide_segments.all().order_by("order"):
279 segment_data = {
280 "peptide_segment.id": segment.id,
281 "peptide_segment.order": segment.order,
282 "peptide_segment.start": segment.start,
283 "peptide_segment.end": segment.end,
284 }
285 peptide_segments.append(segment_data)
286 peptide_data["peptide_segments"] = peptide_segments
287 peptide_list.append(peptide_data)
288 cds_data["peptide_list"] = peptide_list
290 cds_list.append(cds_data)
291 _data["cds_list"] = cds_list
293 sample_data.append(_data)
294 # sample_data =queryset.values()
295 return Response(data=sample_data, status=status.HTTP_200_OK)
298class ReferenceViewSet(
299 viewsets.GenericViewSet,
300 generics.mixins.ListModelMixin,
301 generics.mixins.RetrieveModelMixin,
302):
303 queryset = models.Reference.objects.all()
304 serializer_class = ReferenceSerializer
306 @action(detail=False, methods=["post"])
307 def import_gbk(self, request: Request, *args, **kwargs):
308 if not request.FILES or "gbk_file" not in request.FILES:
309 return Response(
310 {"detail": "No file uploaded."}, status=status.HTTP_400_BAD_REQUEST
311 )
312 if "translation_id" not in request.data:
313 return Response(
314 {"detail": "No translation_id provided."},
315 status=status.HTTP_400_BAD_REQUEST,
316 )
317 translation_id = int(request.data.get("translation_id"))
318 enable_segment = strtobool(request.data.get("segment"))
319 # if enable_segment:
320 # gbk_files = request.FILES.getlist("gbk_file")
321 # import_gbk_files(gbk_files, translation_id)
322 # else:
323 gbk_file = request.FILES.getlist("gbk_file")
324 import_gbk_file(gbk_file, translation_id)
326 return Response(
327 {"detail": "File uploaded successfully"}, status=status.HTTP_201_CREATED
328 )
330 @action(detail=False, methods=["get"])
331 def dataset_options(self, request, *args, **kwargs):
332 """
333 For each organism in the Reference table, returns the corresponding
334 accession values found int the Reference table and
335 dataset values found in the Sample table.
336 """
337 queryset = models.Sample.objects.values(
338 accession=F("sequences__alignments__replicon__reference__accession"),
339 organism=F("sequences__alignments__replicon__reference__organism"),
340 data_set_value=F("data_set"),
341 ).distinct()
343 result = {}
345 for reference in queryset:
346 organism = reference["organism"]
347 accession = reference["accession"]
348 data_set_value = reference["data_set_value"]
350 # Skip entries where organism is None
351 if organism is None:
352 continue
354 if organism not in result:
355 result[organism] = {"accessions": set(), "data_sets": set()}
356 if accession is not None:
357 result[organism]["accessions"].add(accession)
358 if data_set_value is not None:
359 if data_set_value == "":
360 # NOTE: if "-Empty-" string is changed here, it must be changed as well in samples.ts !!
361 result[organism]["data_sets"].add("-Empty-")
362 else:
363 result[organism]["data_sets"].add(data_set_value)
365 # sort data_set values
366 for organism in result:
367 result[organism]["data_sets"] = sorted(result[organism]["data_sets"])
369 return Response(
370 result,
371 status=status.HTTP_200_OK,
372 )
374 @action(detail=False, methods=["post"])
375 def delete_reference(self, request: Request, *args, **kwargs):
376 if "accession" not in request.data:
377 return Response(
378 {"detail": "No accession provided."}, status=status.HTTP_400_BAD_REQUEST
379 )
381 accession = request.data.get("accession")
383 data = delete_reference(accession)
384 return Response(data, status=status.HTTP_200_OK)
386 @action(detail=False, methods=["get"])
387 def distinct_accessions(self, request: Request, *args, **kwargs):
388 accession_list = models.Reference.objects.values_list(
389 "accession", flat=True
390 ).distinct()
391 return Response(data=accession_list, status=status.HTTP_200_OK)
393 @action(detail=False, methods=["get"])
394 def get_all_references(self, request: Request, *args, **kwargs):
395 queryset = models.Reference.objects.all()
396 reference_data = queryset.values()
397 return Response(data=reference_data, status=status.HTTP_200_OK)
399 @action(detail=False, methods=["get"])
400 def get_reference_file(self, request: Request, *args, **kwargs):
401 reference = request.query_params.get("reference")
402 # get Reference and extract name (path)
403 queryset = models.Reference.objects.filter(accession=reference)
404 if queryset.exists():
405 reference_obj = queryset.first()
406 reference_files = reference_obj.name.split(
407 ", "
408 ) # Split the stored file paths
410 # Fetch associated replicon accessions
411 replicons = models.Replicon.objects.filter(reference=reference_obj)
412 replicon_accessions = list(replicons.values_list("accession", flat=True))
413 if len(reference_files) == 1:
414 # Only one file, send it directly
415 reference_file = reference_files[0]
416 response = FileResponse(
417 open(reference_file, "rb"),
418 as_attachment=True,
419 filename=os.path.basename(reference_file),
420 )
422 # Add replicon_accessions to the response headers
423 response["Replicon-Accessions"] = ",".join(replicon_accessions)
424 return response
425 else:
426 # Multiple files -> Zip them
427 zip_buffer = io.BytesIO()
429 with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
430 for ref_file in reference_files:
431 with open(ref_file, "rb") as f:
432 zipf.writestr(os.path.basename(ref_file), f.read())
434 zip_buffer.seek(0)
436 response = FileResponse(
437 zip_buffer,
438 as_attachment=True,
439 filename=f"{reference}.segment.zip",
440 content_type="application/zip",
441 )
442 # Add replicon_accessions to the response headers
443 response["Replicon-Accessions"] = ",".join(replicon_accessions)
444 return response
445 else:
446 return Response(
447 {"detail": "No reference found"}, status=status.HTTP_404_NOT_FOUND
448 )
450 # multilple get in one.
453class PropertyViewSet(
454 viewsets.GenericViewSet,
455 generics.mixins.ListModelMixin,
456 generics.mixins.RetrieveModelMixin,
457):
458 queryset = models.Sample2Property.objects.all()
459 serializer_class = PropertySerializer
460 filter_backends = [DjangoFilterBackend]
461 filterset_fields = ["sample__id", "property__name"]
463 @action(detail=False, methods=["get"])
464 def distinct_properties(self, request: Request, *args, **kwargs):
465 if not (property_name := request.query_params.get("property_name")):
466 return Response(
467 "No property_name provided.", status=status.HTTP_400_BAD_REQUEST
468 )
469 sample_property_fields = [
470 field.name for field in models.Sample._meta.get_fields()
471 ]
472 if property_name in sample_property_fields:
473 queryset = models.Sample.objects.all()
474 if ref := request.query_params.get("reference"):
475 queryset = queryset.filter(
476 sequences__alignments__replicon__reference__accession=ref
477 )
478 queryset = queryset.distinct(property_name)
479 return Response(
480 {"values": [getattr(item, property_name) for item in queryset]},
481 status=status.HTTP_200_OK,
482 )
483 else:
484 queryset = models.Sample2Property.objects.filter(
485 property__name=property_name
486 )
487 if ref := request.query_params.get("reference"):
488 queryset = queryset.filter(
489 sample__sequences__alignments__replicon__reference__accession=ref
490 )
491 datatype = queryset[0].property.datatype
492 queryset = queryset.distinct(datatype)
493 return Response(
494 {"values": [getattr(item, datatype) for item in queryset]},
495 status=status.HTTP_200_OK,
496 )
498 @action(detail=False, methods=["get"])
499 def distinct_property_names(self, request: Request, *args, **kwargs):
500 property_names = self.get_distinct_property_names()
501 return Response(
502 data={"property_names": property_names}, status=status.HTTP_200_OK
503 )
505 @action(detail=False, methods=["post"])
506 def add_property(self, request: Request, *args, **kwargs):
507 name = request.data.get("name")
508 datatype = request.data.get("datatype", None)
509 querytype = request.data.get("querytype", None)
510 description = request.data.get("description", None)
511 default = parse_default_data(request.data.get("default", None))
512 obj, created = find_or_create_property(
513 name=name,
514 datatype=datatype,
515 querytype=querytype,
516 description=description,
517 default=default,
518 )
520 if created:
521 return Response(
522 {"detail": "Property added successfully"},
523 status=status.HTTP_201_CREATED,
524 )
525 elif obj:
526 return Response(
527 {"detail": "Property already exists"}, status=status.HTTP_200_OK
528 )
529 else:
530 return Response(
531 {"detail": "Failed to add property"},
532 status=status.HTTP_500_INTERNAL_SERVER_ERROR,
533 )
535 @action(detail=False, methods=["post"])
536 def delete_property(self, request: Request, *args, **kwargs):
537 name = request.data.get("name")
538 deleted = delete_property(name)
540 if deleted is not False:
541 if deleted == 0:
542 return Response(
543 {"detail": "No matching property found for deletion"},
544 status=status.HTTP_200_OK,
545 )
546 return Response(
547 {"detail": "Property deleted successfully"},
548 status=status.HTTP_200_OK,
549 )
550 else:
551 return Response(
552 {"detail": "Error occurred, please inform the admin."},
553 status=status.HTTP_500_INTERNAL_SERVER_ERROR,
554 )
556 @action(detail=False, methods=["get"])
557 def get_all_properties(self, request: Request, *args, **kwargs):
558 """
559 get all properties with their unique value counts.
561 Returns:
562 Response with format:
563 {
564 "keys": ["name", "query_type", "description", "default", "unique_values_count"],
565 "values": [
566 {"name": "lineage", "query_type": "value_varchar", "unique_values_count": 850},
567 ...
568 ]
569 }
570 """
571 ref = request.query_params.get("reference")
573 # STEP 1: Check cache first (instant return if cached)
574 cache_key = (
575 f"all_properties:{hashlib.md5(ref.encode() if ref else b'').hexdigest()}"
576 )
577 cached_result = cache.get(cache_key)
578 if cached_result is not None:
579 return Response(data=cached_result, status=status.HTTP_200_OK)
581 # STEP 2: Define property categories
582 # Properties stored directly in Sample table
583 default_properties = [
584 "name",
585 "collection_date",
586 "lineage",
587 "zip_code",
588 "lab",
589 "sequencing_tech",
590 "host",
591 "genome_completeness",
592 "country",
593 "init_upload_date",
594 "last_update_date",
595 ]
597 # Properties stored in Sequence table
598 sequence_properties = ["length"]
600 # STEP 3: Build initial property metadata list
601 data_list = [
602 {
603 "name": "name",
604 "query_type": "value_varchar",
605 "description": "sample name or ID (fixed prop.)",
606 "default": None,
607 },
608 {
609 "name": "collection_date",
610 "query_type": "value_date",
611 "description": "Date when the sample was collected (predefined prop.)",
612 "default": None,
613 },
614 {
615 "name": "lab",
616 "query_type": "value_varchar",
617 "description": "Name of the laboratory where the sample was analyzed (predefined prop.)",
618 "default": None,
619 },
620 {
621 "name": "zip_code",
622 "query_type": "value_varchar",
623 "description": "ZIP code of the location where the sample was collected (predefined prop.)",
624 "default": None,
625 },
626 {
627 "name": "host",
628 "query_type": "value_varchar",
629 "description": "Host organism from which the sample was taken (e.g., Human) (predefined prop.)",
630 "default": None,
631 },
632 {
633 "name": "genome_completeness",
634 "query_type": "value_varchar",
635 "description": "Completeness of the genome (e.g., partial or complete) (predefined prop.)",
636 "default": None,
637 },
638 {
639 "name": "lineage",
640 "query_type": "value_varchar",
641 "description": "Lineage (predefined prop.)",
642 "default": None,
643 },
644 {
645 "name": "sequencing_tech",
646 "query_type": "value_varchar",
647 "description": "Technology used for sequencing the genome (predefined prop.)",
648 "default": None,
649 },
650 {
651 "name": "country",
652 "query_type": "value_varchar",
653 "description": "Country where the sample was collected (predefined prop.)",
654 "default": None,
655 },
656 {
657 "name": "init_upload_date",
658 "query_type": "value_date",
659 "description": "Date when the sample data was initially uploaded to the database (fixed prop.)",
660 "default": "current date and time",
661 },
662 {
663 "name": "last_update_date",
664 "query_type": "value_date",
665 "description": "Date when the sample data was last updated in the database (fixed prop.)",
666 "default": "current date and time",
667 },
668 {
669 "name": "data_set",
670 "query_type": "value_varchar",
671 "description": "Name of the data set",
672 "default": None,
673 },
674 {
675 "name": "length",
676 "query_type": "value_integer",
677 "description": "Sequence length in base pairs",
678 "default": None,
679 },
680 ]
682 for _property_queryset in models.Property.objects.order_by("name"):
683 data_list.append(
684 {
685 "name": _property_queryset.name,
686 "query_type": _property_queryset.datatype,
687 "description": _property_queryset.description,
688 "default": _property_queryset.default,
689 }
690 )
691 # STEP 6: Count unique values for each property
692 # For each property, get count of unique values
693 # Problems:
694 # 1. repeats JOIN chain for each property (data_list )
695 # sequences__alignments__replicon__reference__accession 4 JOINs * n properties
696 # - if using IN clause instead of JOIN chains (~700-1000ms vs ~300-500ms)
697 # (no improvment, Django JOIN do the optimization and magic ~300ms)
698 for property_data in data_list:
699 property_name = property_data["name"]
700 # CASE 1: Default properties from Sample table
701 if property_name in default_properties:
702 try:
703 # get count of unique values from Sample table
704 count = (
705 models.Sample.objects.annotate(
706 raw_value=Cast(property_name, output_field=CharField())
707 )
708 .values_list("raw_value", flat=True)
709 .exclude(raw_value__isnull=True)
710 .exclude(raw_value__exact="")
711 .distinct()
712 .count()
713 )
714 except Exception as e:
715 LOGGER.error(f"Error counting {property_name}: {e}")
716 count = 0
717 # CASE 2: Sequence properties from Sequence table
718 elif property_name in sequence_properties:
719 # Special handling for properties stored in Sequence table
720 try:
721 count = (
722 models.Sequence.objects.filter(
723 alignments__replicon__reference__accession=ref
724 )
725 .annotate(
726 raw_value=Cast(property_name, output_field=CharField())
727 )
728 .values_list("raw_value", flat=True)
729 .exclude(raw_value__isnull=True)
730 .exclude(raw_value__exact="")
731 .distinct()
732 .count()
733 )
734 except Exception as e:
735 LOGGER.error(f"Error counting {property_name}: {e}")
736 count = 0
737 # CASE 3: Custom properties from Sample2Property table
738 else:
739 query_type = property_data["query_type"]
740 # get count of unique values from Property table
741 try:
742 count = (
743 models.Sample2Property.objects.filter(
744 property__name=property_name
745 )
746 .filter(
747 sample__sequences__alignments__replicon__reference__accession=ref
748 )
749 .annotate(raw_value=Cast(query_type, output_field=CharField()))
750 .values_list("raw_value", flat=True)
751 .exclude(raw_value__isnull=True)
752 .exclude(raw_value__exact="")
753 .distinct()
754 .count()
755 )
756 except Exception as e:
757 LOGGER.error(f"Error counting {property_name}: {e}")
758 count = 0
760 property_data["unique_values_count"] = count
762 cols = [
763 "name",
764 "query_type",
765 "description",
766 "default",
767 "unique_values_count",
768 ]
769 data = {"keys": cols, "values": data_list}
771 # Cache for xx minutes
772 cache.set(cache_key, data, CACHE_OBJECT_TTL)
774 return Response(data=data, status=status.HTTP_200_OK)
776 @staticmethod
777 def get_distinct_property_names():
778 queryset = models.Property.objects.all()
779 queryset = queryset.distinct("name")
780 filter_list = ["id", "datahash", "properties"]
781 property_names = [item.name for item in queryset]
782 sample_properties = [
783 field.name
784 for field in models.Sample._meta.get_fields()
785 if field.name not in filter_list
786 ]
787 property_names += sample_properties
788 return property_names
790 @staticmethod
791 def get_custom_property_names():
792 queryset = models.Property.objects.all()
793 queryset = queryset.distinct("name")
794 property_names = [item.name for item in queryset]
795 return property_names
798class ResourceViewSet(viewsets.ViewSet):
799 @action(detail=False, methods=["get"])
800 def get_translation_table(self, request: Request):
801 # file path -> resource/1.tt
803 file_path = os.path.join("resource", "1.tt")
804 try:
805 with open(file_path, "rb") as file:
806 translation_table = pickle.load(file)
807 except FileNotFoundError:
808 return Response(
809 {"detail": "error: File not found"}, status=status.HTTP_400_BAD_REQUEST
810 )
811 return Response(data=translation_table)
814class FileUploadViewSet(viewsets.ViewSet):
815 def _convert_property_column_mapping(
816 self, column_mapping: dict[str, str]
817 ) -> dict[str, PropertyColumnMapping]:
818 return {
819 db_property_name: PropertyColumnMapping(**db_property_info)
820 for db_property_name, db_property_info in column_mapping.items()
821 }
823 @action(detail=False, methods=["post"])
824 def import_upload(self, request, *args, **kwargs):
825 # Step 1: Check if zip file is present in the request
826 if "zip_file" not in request.FILES:
827 return Response(
828 {"detail": "No zip file uploaded."}, status=status.HTTP_400_BAD_REQUEST
829 )
831 zip_file = request.FILES.get("zip_file")
832 jobID = request.data.get("job_id", None)
833 # Generate jobID if not provided
834 if jobID is None or jobID == "":
835 jobID = "backend_" + str(uuid.uuid4()) # 32 chars
837 # Step 2: Check if this is a property upload (based on jobID)
838 if "_prop" in jobID:
839 # Property upload: Check for sample_id_column, sequences_id_column and column_mapping
840 sample_id_column = request.data.get("sample_id_column")
841 sequences_id_column = request.data.get("sequences_id_column")
842 column_mapping_json = request.data.get("column_mapping")
844 # Validate sample_id_column
845 if not sample_id_column:
846 return Response(
847 {"detail": "No sample_id_column is provided"},
848 status=status.HTTP_400_BAD_REQUEST,
849 )
850 if not sequences_id_column:
851 LOGGER.info(
852 "No sequences_id_column is provided, sample names are equal to sequence names"
853 )
854 # Validate column_mapping
855 if column_mapping_json == "{}" or column_mapping_json is None:
856 LOGGER.info(
857 "No column_mapping is provided, samples imported into samples table without meta data"
858 )
859 column_mapping = {}
860 else:
861 # Convert column_mapping from JSON to dict
862 column_mapping = self._convert_property_column_mapping(
863 json.loads(column_mapping_json)
864 )
865 if not column_mapping:
866 return Response(
867 {"detail": "No column_mapping could be processed."},
868 status=status.HTTP_400_BAD_REQUEST,
869 )
871 filename = (
872 datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S.%f")[:-3]
873 + "."
874 + jobID
875 )
876 pickle_path = os.path.join(SONAR_DATA_ENTRY_FOLDER, f"{filename}.pkl")
877 with open(pickle_path, "wb") as pickle_file:
878 # Save the sample_id_column and column_mapping as a dictionary
879 pickle.dump(
880 {
881 "sample_id_column": sample_id_column,
882 "sequences_id_column": sequences_id_column,
883 "column_mapping": column_mapping,
884 },
885 pickle_file,
886 )
887 # after save the pickle
888 filename = filename + ".zip"
889 else:
890 filename = (
891 datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S.%f")[:-3]
892 + "."
893 + str(uuid.uuid4().hex)[:6]
894 + ".zip"
895 )
897 # Step 3: Save the zip file
898 save_path = os.path.join(SONAR_DATA_ENTRY_FOLDER, filename)
899 with open(save_path, "wb") as destination:
900 for chunk in zip_file.chunks():
901 destination.write(chunk)
903 # Extract files from the BytesIO
904 # with zipfile.ZipFile(zip_file, "r") as zip_ref:
905 # zip_ref.extractall(SONAR_DATA_ENTRY_FOLDER)
906 # to view list of files and file details in ZIP
907 # for file_info in zip_ref.infolist():
908 # print(file_info)
909 LOGGER.debug(f"jobID: {jobID}")
910 try:
911 proJobID_obj = models.ProcessingJob.objects.get(job_name=jobID)
912 LOGGER.debug(f"ProcessingJob object: {proJobID_obj}")
913 except:
914 LOGGER.debug(f"object with job id={jobID} does not exist yet")
915 LOGGER.debug("Continuing processing...")
917 # Step 4: Register job in database
918 try:
919 proJobID_obj, _ = models.ProcessingJob.objects.get_or_create(
920 status="Q", job_name=jobID
921 )
922 except IntegrityError as e:
923 proJobID_obj = models.ProcessingJob.objects.get(job_name=jobID)
925 models.FileProcessing.objects.create(
926 file_name=filename, processing_job_id=proJobID_obj.id
927 )
928 check_for_new_data()
929 return Response(
930 {"detail": "File uploaded successfully", "jobID": jobID},
931 status=status.HTTP_201_CREATED,
932 )
934 @action(detail=False, methods=["get"])
935 def start_file_import(self, request, *args, **kwargs):
936 check_for_new_data()
937 return Response(
938 {"detail": "File uploaded successfully"}, status=status.HTTP_201_CREATED
939 )
942class LineageViewSet(
943 viewsets.GenericViewSet,
944 generics.mixins.ListModelMixin,
945 generics.mixins.RetrieveModelMixin,
946):
947 model = models.Lineage
948 queryset = models.Lineage.objects.all()
949 serializer_class = LineagesSerializer
950 filter_backends = [DjangoFilterBackend]
951 filterset_fields = ["name", "parent"]
953 @action(detail=False, methods=["get"])
954 def distinct_lineages(self, request: Request, *args, **kwargs):
955 """
956 API action to return all distinct lineage entries from the Sample table.
957 """
958 queryset = models.Sample.objects.values_list("lineage", flat=True)
959 if ref := request.query_params.get("reference"):
960 queryset = queryset.filter(
961 sequences__alignments__replicon__reference__accession=ref
962 )
963 distinct_lineages = queryset.distinct()
965 return Response(
966 {"lineages": distinct_lineages},
967 status=status.HTTP_200_OK,
968 )
970 @action(detail=False, methods=["get"])
971 def full_lineages(self, request: Request, *args, **kwargs):
972 """
973 API action to return all lineages from the Lineage table.
974 """
975 lineages = models.Lineage.objects.order_by("name").values_list(
976 "name", flat=True
977 )
978 return Response(data={"lineages": list(lineages)}, status=status.HTTP_200_OK)
980 @action(detail=True, methods=["get"])
981 def get_sublineages(self, request: Request, *args, **kwargs):
982 lineage = self.get_object()
983 sublineages = lineage.get_sublineages()
984 print(len(sublineages))
985 list = [str(lineage) for lineage in sublineages]
986 list.sort()
987 return Response(data={"sublineages": list}, status=status.HTTP_200_OK)
989 @action(detail=False, methods=["put"])
990 def update_lineages(self, request: Request, *args, **kwargs):
991 accession = request.data.get("reference")
992 if not accession:
993 return Response(
994 {"detail": "reference field is required"},
995 status=status.HTTP_400_BAD_REQUEST,
996 )
997 try:
998 reference = models.Reference.objects.get(accession=accession)
999 except models.Reference.DoesNotExist:
1000 return Response(
1001 {"detail": f"Reference '{accession}' not found"},
1002 status=status.HTTP_400_BAD_REQUEST,
1003 )
1004 tsv_file = request.FILES.get("lineages_file")
1005 tsv_file = self._temp_save_file(tsv_file)
1006 lineage_import = LineageImport()
1007 lineage_import.set_file(tsv_file)
1008 # Only replace this reference's lineages, keep the others intact.
1009 models.Lineage.objects.filter(reference=reference).delete()
1010 lineage_import.process_lineage_data(reference)
1011 return Response(
1012 {"detail": "Lineages updated successfully"}, status=status.HTTP_200_OK
1013 )
1015 def _temp_save_file(self, uploaded_file: InMemoryUploadedFile):
1016 file_path = os.path.join(SONAR_DATA_ENTRY_FOLDER, uploaded_file.name)
1017 with open(file_path, "wb") as f:
1018 f.write(uploaded_file.read())
1019 return file_path
1022class TasksView(
1023 viewsets.GenericViewSet,
1024):
1025 serializer_class = (
1026 ProcessingJobSerializer # Specify the serializer class for the model
1027 )
1029 @action(detail=False, methods=["get"]) # detail=False means it's a list action
1030 def generate_job_id(self, request, *args, **kwargs):
1031 is_prop = strtobool(request.query_params.get("is_prop", "False"))
1032 job_id = generate_job_ID(is_prop)
1034 return Response(data={"job_id": job_id}, status=status.HTTP_200_OK)
1036 @action(detail=False, methods=["get"]) # detail=False means it's a list action
1037 def get_all_jobs(self, request, *args, **kwargs):
1038 # Retrieve all ProcessingJob instances
1039 jobs = models.ProcessingJob.objects.all()
1040 # Serialize the queryset
1041 serializer = self.get_serializer(jobs, many=True)
1042 # Return serialized data in the response
1043 return Response(data={"detail": serializer.data}, status=status.HTTP_200_OK)
1045 # get by job id
1046 @action(detail=False, methods=["get"])
1047 def get_files_by_job_id(self, request, *args, **kwargs):
1048 try:
1049 if job_id := request.query_params.get("job_id"):
1050 jobID_obj = models.ProcessingJob.objects.get(job_name=job_id)
1051 else:
1052 return Response(
1053 {"detail": "job_id field is missing"},
1054 status=status.HTTP_400_BAD_REQUEST,
1055 )
1057 # Retrieve all FileProcessing instances associated with the job
1058 files = models.FileProcessing.objects.filter(
1059 processing_job__job_name=job_id
1060 )
1061 # Serialize the FileProcessing instances
1062 # file_serializer = FileProcessingSerializer(files, many=True)
1064 # # Retrieve ImportLog instances for each file and their status
1066 files_data = []
1067 for file in files:
1068 logs = models.ImportLog.objects.filter(file=file)
1069 logs_data = ImportLogSerializer(logs, many=True).data
1070 files_data.append(
1071 {"file_name": file.file_name, "status_list": logs_data}
1072 )
1073 return Response(
1074 data={
1075 "jobID": job_id,
1076 "status": jobID_obj.status,
1077 "detail": files_data,
1078 },
1079 status=status.HTTP_200_OK,
1080 )
1081 except models.ProcessingJob.DoesNotExist:
1082 return Response(
1083 data={"detail": f"Job not found ({job_id})"},
1084 status=status.HTTP_400_BAD_REQUEST,
1085 )