Completed
Push — master ( 8738db...eecf08 )
by Paolo
06:03
created

crbanim.helpers.sanitize_url()   A

Complexity

Conditions 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 2
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nop 1
1
#!/usr/bin/env python3
2
# -*- coding: utf-8 -*-
3
"""
4
Created on Thu Feb 21 15:37:16 2019
5
6
@author: Paolo Cozzi <[email protected]>
7
"""
8
9
import io
10
import csv
11
import urllib
12
import logging
13
import pycountry
14
15
from collections import defaultdict, namedtuple
16
17
from django.utils.dateparse import parse_date
18
19
from common.constants import LOADED, ERROR, MISSING, SAMPLE_STORAGE
20
from common.helpers import image_timedelta
21
from image_app.helpers import (
22
    FileDataSourceMixin, get_or_create_obj, update_or_create_obj)
23
from image_app.models import (
24
    DictSpecie, DictSex, DictCountry, DictBreed, Name, Animal, Sample,
25
    DictUberon, Publication)
26
from submissions.helpers import send_message
27
from validation.helpers import construct_validation_message
28
from validation.models import ValidationSummary
29
30
# Get an instance of a logger
31
logger = logging.getLogger(__name__)
32
33
34
# A class to deal with cryoweb import errors
35
class CRBAnimImportError(Exception):
36
    pass
37
38
39
class CRBAnimReader(FileDataSourceMixin):
40
    mandatory_columns = [
41
            'sex',
42
            'species_latin_name',
43
            'country_of_origin',
44
            'breed_name',
45
            'animal_ID',
46
            'sample_bibliographic_references',
47
            'sample_identifier',
48
            'animal_birth_date',
49
            'sample_storage_temperature',
50
            'sample_type_name',
51
            'body_part_name',
52
            'sampling_date',
53
            'sampling_protocol_url',
54
            'sample_availability',
55
            'EBI_Biosample_identifier',
56
        ]
57
58
    def __init__(self):
59
        self.data = None
60
        self.header = None
61
        self.dialect = None
62
        self.items = None
63
        self.filename = None
64
65
    @classmethod
66
    def get_dialect(cls, chunk):
67
        """Determine dialect of a CSV from a chunk"""
68
69
        return csv.Sniffer().sniff(chunk)
70
71
    @classmethod
72
    def is_valid(cls, chunk):
73
        """Try to determine if CRBanim has at least the required columns
74
        or not"""
75
76
        dialect = cls.get_dialect(chunk)
77
78
        # get a handle from a string
79
        handle = io.StringIO(chunk)
80
81
        # read chunk
82
        reader = csv.reader(handle, dialect)
83
        header = next(reader)
84
85
        not_found = []
86
87
        for column in cls.mandatory_columns:
88
            if column not in header:
89
                not_found.append(column)
90
91
        if len(not_found) == 0:
92
            logger.debug("This seems to be a valid CRBanim file")
93
            return True, []
94
95
        else:
96
            logger.error("Couldn't not find mandatory CRBanim columns %s" % (
97
                not_found))
98
            return False, not_found
99
100
    def read_file(self, filename):
101
        """Read crb anim files and set tit to class attribute"""
102
103
        with open(filename, newline='') as handle:
104
            # initialize data
105
            self.filename = filename
106
            self.data = []
107
108
            # get dialect
109
            chunk = handle.read(2048)
110
            self.dialect = self.get_dialect(chunk)
111
112
            # restart filename from the beginning
113
            handle.seek(0)
114
115
            # read csv file
116
            reader = csv.reader(handle, self.dialect)
117
            self.header = next(reader)
118
119
            # find sex index column
120
            sex_idx = self.header.index('sex')
121
122
            # create a namedtuple object
123
            Data = namedtuple("Data", self.header)
124
125
            # add records to data
126
            for record in reader:
127
                # replace all "\\N" occurences in a list
128
                record = [None if col in ["\\N", ""]
129
                          else col for col in record]
130
131
                # 'unknown' sex should be replaced with 'record of unknown sex'
132
                if record[sex_idx].lower() == 'unknown':
133
                    logger.debug(
134
                        "Changing '%s' with '%s'" % (
135
                            record[sex_idx], 'record of unknown sex'))
136
                    record[sex_idx] = 'record of unknown sex'
137
138
                record = Data._make(record)
139
                self.data.append(record)
140
141
        self.items = self.eval_columns()
142
143
    def eval_columns(self):
144
        """define a set from column data"""
145
146
        # target_columns = ['sex', 'species_latin_name', 'breed_name']
147
        target_columns = self.header
148
149
        items = defaultdict(list)
150
151
        for line in self.data:
152
            for column in target_columns:
153
                idx = self.header.index(column)
154
                items[column].append(line[idx])
155
156
        # now get a set of object
157
        for column in target_columns:
158
            items[column] = set(items[column])
159
160
        return items
161
162
    def print_line(self, num):
163
        """print a record with its column names"""
164
165
        for i, column in enumerate(self.header):
166
            logger.debug("%s: %s" % (column, self.data[num][i]))
167
168
    def filter_by_column_values(self, column, values, ignorecase=False):
169
        if ignorecase is True:
170
            # lower values
171
            values = [value.lower() for value in values]
172
173
        for line in self.data:
174
            # search for case insensitive value (lower attrib in lower values)
175
            if ignorecase is True:
176
                if getattr(line, column).lower() in values:
177
                    yield line
178
179
                else:
180
                    logger.debug("Filtering: %s" % (str(line)))
181
182
            else:
183
                if getattr(line, column) in values:
184
                    yield line
185
186
                else:
187
                    logger.debug("Filtering: %s" % (str(line)))
188
189
            # ignore case or not
190
191
        # cicle for line
192
193
    # a function to detect if crbanim species are in UID database or not
194
    def check_species(self, country):
195
        """Check if all species are defined in UID DictSpecies"""
196
197
        # CRBAnim usually have species in the form required for UID
198
        # However sometimes there could be a common name, not a DictSpecie one
199
        column = 'species_latin_name'
200
        item_set = self.items[column]
201
202
        # call FileDataSourceMixin.check_species
203
        return super().check_species(column, item_set, country)
204
205
    # check that dict sex table contains data
206
    def check_sex(self):
207
        """check that dict sex table contains data"""
208
209
        # item.sex are in uppercase
210
        column = 'sex'
211
        item_set = [item.lower() for item in self.items[column]]
212
213
        # call FileDataSourceMixin.check_items
214
        return self.check_items(item_set, DictSex, column)
215
216
217
def fill_uid_breed(record, language):
218
    """Fill DictBreed from a crbanim record"""
219
220
    # get a DictSpecie object. Species are in latin names, but I can
221
    # find also a common name in translation tables
222
    specie = DictSpecie.get_specie_check_synonyms(
223
            species_label=record.species_latin_name,
224
            language=language)
225
226
    # get country name using pycountries
227
    country_name = pycountry.countries.get(
228
        alpha_2=record.country_of_origin).name
229
230
    # get country for breeds. Ideally will be the same of submission,
231
    # however, it could be possible to store data from other contries
232
    country = get_or_create_obj(
233
        DictCountry,
234
        label=country_name)
235
236
    breed = get_or_create_obj(
237
        DictBreed,
238
        supplied_breed=record.breed_name,
239
        specie=specie,
240
        country=country)
241
242
    # return a DictBreed object
243
    return breed
244
245
246
def fill_uid_names(record, submission):
247
    """fill Names table from crbanim record"""
248
249
    # in the same record I have the sample identifier and animal identifier
250
    # a name record for animal
251
    animal_name = get_or_create_obj(
252
        Name,
253
        name=record.animal_ID,
254
        submission=submission,
255
        owner=submission.owner)
256
257
    # get a publication (if present)
258
    publication = None
259
260
    if record.sample_bibliographic_references:
261
        publication = get_or_create_obj(
262
            Publication,
263
            doi=record.sample_bibliographic_references)
264
265
    # name record for sample
266
    sample_name = get_or_create_obj(
267
        Name,
268
        name=record.sample_identifier,
269
        submission=submission,
270
        owner=submission.owner,
271
        publication=publication)
272
273
    # returning 2 Name instances
274
    return animal_name, sample_name
275
276
277
def fill_uid_animal(record, animal_name, breed, submission, animals):
278
    """Helper function to fill animal data in UID animal table"""
279
280
    # HINT: does CRBAnim models mother and father?
281
282
    # check if such animal is already beed updated
283
    if animal_name.name in animals:
284
        logger.debug(
285
            "Ignoring %s: already created or updated" % (animal_name))
286
287
        # return an animal object
288
        animal = animals[animal_name.name]
289
290
    else:
291
        # determine sex. Check for values
292
        sex = DictSex.objects.get(label__iexact=record.sex)
293
294
        # there's no birth_location for animal in CRBAnim
295
        accuracy = MISSING
296
297
        # create a new object. Using defaults to avoid collisions when
298
        # updating data
299
        # HINT: CRBanim has less attribute than cryoweb
300
        defaults = {
301
            # HINT: is a duplication of name. Can this be non-mandatory?
302
            'alternative_id': animal_name.name,
303
            'breed': breed,
304
            'sex': sex,
305
            'birth_date': record.animal_birth_date,
306
            'birth_location_accuracy': accuracy,
307
            'owner': submission.owner
308
        }
309
310
        # HINT: I could have the same animal again and again. Should I update
311
        # every times?
312
        animal = update_or_create_obj(
313
            Animal,
314
            name=animal_name,
315
            defaults=defaults)
316
317
        # track this animal in dictionary
318
        animals[animal_name.name] = animal
319
320
    # I need to track animal to relate the sample
321
    return animal
322
323
324
def find_storage_type(record):
325
    """Determine a sample storage relying on a dictionary"""
326
327
    mapping = {
328
        '-196°C': 'frozen, liquid nitrogen',
329
        '-20°C': 'frozen, -20 degrees Celsius freezer',
330
        '-30°C': 'frozen, -20 degrees Celsius freezer',
331
        '-80°C': 'frozen, -80 degrees Celsius freezer'}
332
333
    if record.sample_storage_temperature in mapping:
334
        # get ENUM conversion
335
        storage = SAMPLE_STORAGE.get_value_by_desc(
336
            mapping[record.sample_storage_temperature])
337
338
        return storage
339
340
    else:
341
        logging.warning("Couldn't find %s in storage types mapping" % (
342
            record.sample_storage_temperature))
343
344
        return None
345
346
347
def sanitize_url(url):
348
    """Quote URLs for accession"""
349
350
    return urllib.parse.quote(url, ':/#?=')
351
352
353
def fill_uid_sample(record, sample_name, animal, submission):
354
    """Helper function to fill animal data in UID sample table"""
355
356
    # name and animal name come from parameters
357
    organism_part_label = None
358
    sample_type_name = record.sample_type_name.lower()
359
    body_part_name = record.body_part_name.lower()
360
361
    # sylvain has proposed to apply the following decision rule:
362
    if body_part_name != "unknown" and body_part_name != "not relevant":
363
        organism_part_label = body_part_name
364
365
    else:
366
        organism_part_label = sample_type_name
367
368
    # get a organism part. Organism parts need to be in lowercases
369
    organism_part = get_or_create_obj(
370
        DictUberon,
371
        label=organism_part_label
372
    )
373
374
    # calculate animal age at collection
375
    animal_birth_date = parse_date(record.animal_birth_date)
376
    sampling_date = parse_date(record.sampling_date)
377
    animal_age_at_collection, time_units = image_timedelta(
378
        sampling_date, animal_birth_date)
379
380
    # create a new object. Using defaults to avoid collisions when
381
    # updating data
382
    defaults = {
383
        # HINT: is a duplication of name. Can this be non-mandatory?
384
        'alternative_id': sample_name.name,
385
        'collection_date': record.sampling_date,
386
        'protocol': record.sampling_protocol_url,
387
        'organism_part': organism_part,
388
        'animal': animal,
389
        # 'description': v_vessel.comment,
390
        'owner': submission.owner,
391
        'storage': find_storage_type(record),
392
        'availability': sanitize_url(record.sample_availability),
393
        'animal_age_at_collection': animal_age_at_collection,
394
        'animal_age_at_collection_units': time_units
395
    }
396
397
    sample = update_or_create_obj(
398
        Sample,
399
        name=sample_name,
400
        defaults=defaults)
401
402
    return sample
403
404
405
def process_record(record, submission, animals, language):
406
    # Peter mail 26/02/19 18:30: I agree that it sounds like we will
407
    # need to create sameAs BioSamples for the IMAGE project, and it makes
408
    # sense that the inject tool is able to do this.  It may be that we
409
    # tackle these cases after getting the main part of the inject tool
410
    # functioning and hold or ignore these existing BioSamples for now.
411
    # HINT: record with a biosample id should be ignored, for the moment
412
    if record.EBI_Biosample_identifier is not None:
413
        logger.warning("Ignoring %s: already in biosample!" % str(record))
414
        return
415
416
    # filling breeds
417
    breed = fill_uid_breed(record, language)
418
419
    # filling name tables
420
    animal_name, sample_name = fill_uid_names(record, submission)
421
422
    # fill animal
423
    animal = fill_uid_animal(record, animal_name, breed, submission, animals)
424
425
    # fill sample
426
    fill_uid_sample(record, sample_name, animal, submission)
427
428
429
def upload_crbanim(submission):
430
    # debug
431
    logger.info("Importing from CRB-Anim file")
432
433
    # this is the full path in docker container
434
    fullpath = submission.get_uploaded_file_path()
435
436
    # read submission data
437
    reader = CRBAnimReader()
438
    reader.read_file(fullpath)
439
440
    # start data loading
441
    try:
442
        # check for species and sex in a similar way as cryoweb does
443
        check, not_found = reader.check_sex()
444
445
        if not check:
446
            message = (
447
                "Not all Sex terms are loaded into database: "
448
                "check for %s in your dataset" % (not_found))
449
450
            raise CRBAnimImportError(message)
451
452
        check, not_found = reader.check_species(submission.gene_bank_country)
453
454
        if not check:
455
            raise CRBAnimImportError(
456
                "Some species are not loaded in UID database: "
457
                "%s" % (not_found))
458
459
        # ok get languages from submission (useful for translation)
460
        # HINT: no traslations implemented, at the moment
461
        language = submission.gene_bank_country.label
462
463
        # a dictionary in which store animal data
464
        animals = {}
465
466
        for record in reader.data:
467
            process_record(record, submission, animals, language)
468
469
        # after processing records, initilize validationsummary objects
470
        # create a validation summary object and set all_count
471
        vs_animal = get_or_create_obj(
472
            ValidationSummary,
473
            submission=submission,
474
            type="animal")
475
476
        # reset counts
477
        vs_animal.reset_all_count()
478
479
        vs_sample = get_or_create_obj(
480
            ValidationSummary,
481
            submission=submission,
482
            type="sample")
483
484
        # reset counts
485
        vs_sample.reset_all_count()
486
487
    except Exception as exc:
488
        # set message:
489
        message = "Error in importing data: %s" % (str(exc))
490
491
        # save a message in database
492
        submission.status = ERROR
493
        submission.message = message
494
        submission.save()
495
496
        # send async message
497
        send_message(submission)
498
499
        # debug
500
        logger.error("error in importing from crbanim: %s" % (exc))
501
        logger.exception(exc)
502
503
        return False
504
505
    else:
506
        message = "CRBAnim import completed for submission: %s" % (
507
            submission.id)
508
509
        submission.message = message
510
        submission.status = LOADED
511
        submission.save()
512
513
        # send async message
514
        send_message(
515
            submission,
516
            validation_message=construct_validation_message(submission))
517
518
    logger.info("Import from CRBAnim is complete")
519
520
    return True
521