• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IATI / IATI-Stats / 9628049812

14 Feb 2023 05:50PM UTC coverage: 36.163%. Remained the same
9628049812

push

github

web-flow
Merge pull request #37 from codeforIATI/end-to-end-traceability-tests

End to end traceability tests

0 of 16 new or added lines in 1 file covered. (0.0%)

95 existing lines in 1 file now uncovered.

771 of 2132 relevant lines covered (36.16%)

0.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

59.81
/stats/analytics.py
1
"""
2
This is the default stats module used by calculate_stats.py
3
You can choose a different set of tests by running calculate_stats.py with the ``--stats-module`` flag.
4
"""
5

6
from __future__ import print_function
1✔
7
from lxml import etree
1✔
8
from datetime import date, datetime, timedelta
1✔
9
from collections import Counter, defaultdict, OrderedDict
1✔
10
from decimal import Decimal, InvalidOperation
1✔
11
import os
1✔
12
import re
1✔
13
import json
1✔
14
import csv
1✔
15
import copy
1✔
16

17
from dateutil.relativedelta import relativedelta
1✔
18

19
from stats.common.decorators import (
1✔
20
    memoize,
21
    no_aggregation,
22
    returns_dict,
23
    returns_numberdict,
24
    returns_numberdictdict,
25
    returns_number,
26
    returns_numberdictdictdict,
27
)
28
from stats.common import (
1✔
29
    budget_year,
30
    debug,
31
    get_registry_id_matches,
32
    iso_date,
33
    iso_date_match,
34
    planned_disbursement_year,
35
    transaction_date,
36
)
37

38
import iatirulesets
1✔
39
from helpers.currency_conversion import get_USD_value
1✔
40

41

42
def add_years(d, years):
1✔
43
    """Return a date that's `years` years before/after the date (or datetime)
44
    object `d`. Return the same calendar date (month and day) in the
45
    destination year, if it exists, otherwise use the following day
46
    (thus changing February 29 to March 1).
47

48
    Keyword arguments:
49
    d -- a date (or datetime) object
50
    years -- number of years to increment the date. Accepts negative numbers
51

52
    """
53
    try:
1✔
54
        return d.replace(year=d.year + years)
1✔
55
    except ValueError:
×
56
        return d + (date(d.year + years, 1, 1) - date(d.year, 1, 1))
×
57

58

59
def all_true_and_not_empty(bool_iterable):
1✔
60
    """For a given list, check that all elements return true and that the list is not empty.
61

62
    Args:
63
        bool_iterable (iterable of bool): An iterable containing values that can be cast to a bool.
64

65
    """
66

67
    # Ensure that the given list is indeed a simple list
68
    bool_list = list(bool_iterable)
1✔
69

70
    # Perform logic. Check that all elements return true and that the list is not empty
71
    if (all(bool_list)) and (len(bool_list) > 0):
1✔
72
        return True
1✔
73
    else:
74
        return False
1✔
75

76

77
def is_number(v):
1✔
78
    """ Tests if a variable is a number.
79
        Input: s - a variable
80
        Return: True if v is a number
81
                False if v is not a number
82
        NOTE: Any changes to this function should be replicated in:
83
              https://github.com/codeforIATI/analytics/blob/881f950c/coverage.py#L10
84
    """
85
    try:
1✔
86
        float(v)
1✔
87
        return True
1✔
88
    except ValueError:
×
89
        return False
×
90

91

92
def convert_to_float(x):
1✔
93
    """ Converts a variable to a float value, or 0 if it cannot be converted to a float.
94
        Input: x - a variable
95
        Return: x as a float, or zero if x is not a number
96
        NOTE: Any changes to this function should be replicated in:
97
              https://github.com/codeforIATI/analytics/blob/881f950c/coverage.py#L23
98
    """
99
    if is_number(x):
1✔
100
        return float(x)
1✔
101
    else:
102
        return 0
×
103

104

105
# Import codelists
106
# In order to test whether or not correct codelist values are being
107
# used in the data we need to pull in data about how codelists map
108
# to elements
109
def get_codelist_mapping(major_version):
1✔
110
    with open('helpers/mapping-{}.json'.format(major_version)) as f:
1✔
111
        codelist_mappings = json.load(f)
1✔
112
    codelist_condition_paths = []
1✔
113
    for mapping in codelist_mappings:
1✔
114
        path = mapping.get('path')
1✔
115
        path = re.sub(r'^\/\/iati-activity', './', path)
1✔
116
        path = re.sub(r'^\/\/', './/', path)
1✔
117
        condition = mapping.get('condition')
1✔
118
        if condition is not None:
1✔
119
            pref, attr = path.rsplit('/', 1)
1✔
120
            condition_path = '{0}[{1}]/{2}'.format(pref, condition, attr)
1✔
121
            codelist_condition_paths.append(condition_path)
1✔
122
        else:
123
            codelist_condition_paths.append(path)
1✔
124

125
    return codelist_condition_paths
1✔
126

127

128
codelist_mappings = {
1✔
129
    major_version: get_codelist_mapping(major_version)
130
    for major_version in ['1', '2']}
131

132

133
CODELISTS = {'1': {}, '2': {}}
1✔
134
for major_version in ['1', '2']:
1✔
135
    for codelist_name in [
1✔
136
        'Version',
137
        'ActivityStatus',
138
        'Currency',
139
        'Sector',
140
        'SectorCategory',
141
        'DocumentCategory',
142
        'AidType',
143
        'BudgetNotProvided',
144
        'OrganisationRegistrationAgency',
145
        'CRSChannelCode'
146
    ]:
147
        CODELISTS[major_version][codelist_name] = set(
1✔
148
            c['code'] for c in json.load(
149
                open('helpers/codelists/{}/{}.json'.format(major_version, codelist_name))
150
            )['data']
151
        )
152

153

154
def build_org_prefix_list():
1✔
155
    """Build lists of valid organisation identifier prefixes"""
156
    out = {}
1✔
157
    for major_version in ('1', '2'):
1✔
158
        out[major_version] = defaultdict(list)
1✔
159
        for prefix in CODELISTS[major_version]['OrganisationRegistrationAgency']:
1✔
160
            out[major_version][len(prefix)].append(prefix)
1✔
161
    return out
1✔
162

163

164
org_prefix_list = build_org_prefix_list()
1✔
165

166

167
def build_channel_code_list():
1✔
168
    """Build lists of CRS Channel Codes"""
169
    out = {}
1✔
170
    for major_version in ('1', '2'):
1✔
171
        out[major_version] = defaultdict(list)
1✔
172
        for code in CODELISTS[major_version]['CRSChannelCode']:
1✔
173
            out[major_version][code[:2]].append(code)
1✔
174
    return out
1✔
175

176

177
channel_code_list = build_channel_code_list()
1✔
178

179

180
def valid_org_prefix(major_version, org_id):
1✔
181
    """Organisation identifier has valid prefix"""
182
    for n in org_prefix_list[major_version]:
1✔
183
        for prefix in org_prefix_list[major_version][n]:
1✔
184
            if org_id.startswith(prefix):
1✔
185
                return True, prefix
1✔
186
    for n in channel_code_list[major_version]:
1✔
187
        for code in channel_code_list[major_version][n]:
1✔
188
            if org_id.startswith(code):
1✔
189
                return True, code
1✔
190
    return False, str(None)
1✔
191

192

193
# Import country language mappings, and save as a dictionary
194
# Contains a dictionary of ISO 3166-1 country codes (as key) with a list of ISO 639-1 language codes (as value)
195
reader = csv.reader(open('helpers/transparency_indicator/country_lang_map.csv'), delimiter=',')
1✔
196
country_lang_map = {}
1✔
197
for row in reader:
1✔
198
    if row[0] not in country_lang_map.keys():
1✔
199
        country_lang_map[row[0]] = [row[2]]
1✔
200
    else:
201
        country_lang_map[row[0]].append(row[2])
1✔
202

203

204
# Import reference spending data, and save as a dictionary
205
reference_spend_data = {}
1✔
206
with open('helpers/transparency_indicator/reference_spend_data.csv', 'r') as csv_file:
1✔
207
    reader = csv.reader(csv_file, delimiter=',')
1✔
208
    for line in reader:
1✔
209
        pub_registry_id = line[1]
1✔
210

211
        # Update the publisher registry ID, if this publisher has since updated their registry ID
212
        if pub_registry_id in get_registry_id_matches().keys():
1✔
213
            pub_registry_id = get_registry_id_matches()[pub_registry_id]
×
214

215
        reference_spend_data[pub_registry_id] = {'publisher_name': line[0],
1✔
216
                                                 '2014_ref_spend': line[2],
217
                                                 '2015_ref_spend': line[6],
218
                                                 '2015_official_forecast': line[10],
219
                                                 'currency': line[11],
220
                                                 'spend_data_error_reported': True if line[12] == 'Y' else False,
221
                                                 'DAC': True if 'DAC' in line[3] else False}
222

223

224
def element_to_count_dict(element, path, count_dict, count_multiple=False):
1✔
225
    """
226
    Converts an element and it's children to a dictionary containing the
227
    count for each xpath.
228

229
    """
230
    if count_multiple:
1✔
231
        count_dict[path] += 1
1✔
232
    else:
233
        count_dict[path] = 1
1✔
234
    for child in element:
1✔
235
        if type(child.tag) == str:
×
236
            element_to_count_dict(child, path + '/' + child.tag, count_dict, count_multiple)
×
237
    for attribute in element.attrib:
1✔
238
        if str(element.attrib[attribute]) == '':
1✔
239
            continue
×
240
        if count_multiple:
1✔
241
            count_dict[path + '/@' + attribute] += 1
1✔
242
        else:
243
            count_dict[path + '/@' + attribute] = 1
1✔
244
    return count_dict
1✔
245

246

247
def valid_date(date_element):
1✔
248
    if date_element is None:
1✔
249
        return False
1✔
250
    schema_root = etree.XML('''
1✔
251
        <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
252
            <xsd:element name="activity-date" type="dateType"/>
253
            <xsd:element name="transaction-date" type="dateType"/>
254
            <xsd:element name="period-start" type="dateType"/>
255
            <xsd:element name="period-end" type="dateType"/>
256
            <xsd:complexType name="dateType" mixed="true">
257
                <xsd:sequence>
258
                    <xsd:any minOccurs="0" maxOccurs="unbounded" processContents="lax" />
259
                </xsd:sequence>
260
                <xsd:attribute name="iso-date" type="xsd:date" use="required"/>
261
                <xsd:anyAttribute processContents="lax"/>
262
            </xsd:complexType>
263
            <xsd:element name="value">
264
                <xsd:complexType mixed="true">
265
                    <xsd:sequence>
266
                        <xsd:any minOccurs="0" maxOccurs="unbounded" processContents="lax" />
267
                    </xsd:sequence>
268
                    <xsd:attribute name="value-date" type="xsd:date" use="required"/>
269
                    <xsd:anyAttribute processContents="lax"/>
270
                </xsd:complexType>
271
            </xsd:element>
272
        </xsd:schema>
273
    ''')
274
    schema = etree.XMLSchema(schema_root)
1✔
275
    return schema.validate(date_element)
1✔
276

277

278
def valid_url(element):
1✔
279
    if element is None:
1✔
280
        return False
×
281

282
    if element.tag == 'document-link':
1✔
283
        url = element.attrib.get('url')
1✔
284
    elif element.tag == 'activity-website':
1✔
285
        url = element.text
1✔
286
    else:
287
        return False
×
288

289
    if url is None or url == '' or '://' not in url:
1✔
290
        # Return false if it's empty or not an absolute url
291
        return False
1✔
292

293
    schema_root = etree.XML('''
1✔
294
        <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
295
            <xsd:element name="document-link">
296
                <xsd:complexType mixed="true">
297
                    <xsd:sequence>
298
                        <xsd:any minOccurs="0" maxOccurs="unbounded" processContents="lax" />
299
                    </xsd:sequence>
300
                    <xsd:attribute name="url" type="xsd:anyURI" use="required"/>
301
                    <xsd:anyAttribute processContents="lax"/>
302
                </xsd:complexType>
303
            </xsd:element>
304
            <xsd:element name="activity-website">
305
                <xsd:complexType>
306
                    <xsd:simpleContent>
307
                        <xsd:extension base="xsd:anyURI">
308
                            <xsd:anyAttribute processContents="lax"/>
309
                        </xsd:extension>
310
                    </xsd:simpleContent>
311
                </xsd:complexType>
312
            </xsd:element>
313
        </xsd:schema>
314
    ''')
315
    schema = etree.XMLSchema(schema_root)
1✔
316
    return schema.validate(element)
1✔
317

318

319
def valid_value(value_element):
1✔
320
    if value_element is None:
1✔
321
        return False
×
322
    schema_root = etree.XML('''
1✔
323
        <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
324
            <xsd:element name="value">
325
                <xsd:complexType>
326
                    <xsd:simpleContent>
327
                        <xsd:extension base="xsd:decimal">
328
                            <xsd:anyAttribute processContents="lax"/>
329
                        </xsd:extension>
330
                    </xsd:simpleContent>
331
                </xsd:complexType>
332
            </xsd:element>
333
        </xsd:schema>
334
    ''')
335
    schema = etree.XMLSchema(schema_root)
1✔
336
    return schema.validate(value_element)
1✔
337

338

339
def valid_coords(x):
1✔
340
    try:
1✔
341
        coords = x.split(' ')
1✔
342
    except AttributeError:
1✔
343
        return False
1✔
344
    if len(coords) != 2:
1✔
345
        return False
1✔
346
    try:
1✔
347
        lat = Decimal(coords[0])
1✔
348
        lng = Decimal(coords[1])
1✔
349
        # the (0, 0) coordinate is invalid since it's in the ocean in international waters and near-certainly not actual data
350
        if lat == 0 and lng == 0:
1✔
351
            return False
1✔
352
        # values outside the valid (lat, lng) coordinate space are invalid
353
        elif lat < -90 or lat > 90 or lng < -180 or lng > 180:
1✔
354
            return False
1✔
355
        else:
356
            return True
1✔
357
    except InvalidOperation:
1✔
358
        return False
1✔
359

360

361
def get_currency(iati_activity_object, budget_pd_transaction):
1✔
362
    """ Returns the currency used for a budget, planned disbursement or transaction value. This is based
363
        on either the currency specified in value/@currency, or the default currency specified in
364
        iati-activity/@default-currency).
365
    """
366

367
    # Get the default currency (specified in iati-activity/@default-currency)
368
    currency = iati_activity_object.element.attrib.get('default-currency')
1✔
369

370
    # If there is a currency within the value element, overwrite the default currency
371
    if budget_pd_transaction.xpath('value/@currency'):
1✔
372
        currency = budget_pd_transaction.xpath('value/@currency')[0]
1✔
373

374
    # Return the currency
375
    return currency
1✔
376

377

378
def has_xml_lang(obj):
1✔
379
    """Test if an obj has an XML lang attribute declared.
380
       Input: an etree XML object, for example a narrative element
381
       Return: True if @xml:lang is present, or False if not
382
    """
383
    return len(obj.xpath("@xml:lang", namespaces={"xml": "http://www.w3.org/XML/1998/namespace"})) > 0
1✔
384

385

386
def get_language(major_version, iati_activity_obj, title_or_description_obj):
1✔
387
    """ Returns the language (or languages if publishing to version 2.x) used for a single title or
388
        description element. This is based on either the language specified in @xml:lang
389
        (version 1.x) or narrative/@xml:lang (version 2.x), or the default language, as specified
390
        in iati-activity/@xml:lang).
391
        Input: iati_activity_object - An IATI Activity element. Will be self in most cases.
392
        Returns: List of language/s used in the given title_or_description_elem.
393
                 Empty list if no languages specified.
394
    """
395

396
    langs = []
1✔
397

398
    # Get default language for this activity
399
    if has_xml_lang(iati_activity_obj):
1✔
400
        default_lang = iati_activity_obj.xpath("@xml:lang", namespaces={"xml": "http://www.w3.org/XML/1998/namespace"})[0]
1✔
401

402
    if major_version == '2':
1✔
403
        for narrative_obj in title_or_description_obj.findall('narrative'):
1✔
404
            if has_xml_lang(narrative_obj):
1✔
405
                langs.append(narrative_obj.xpath("@xml:lang", namespaces={"xml": "http://www.w3.org/XML/1998/namespace"})[0])
1✔
406
            elif has_xml_lang(iati_activity_obj):
1✔
407
                langs.append(default_lang)
1✔
408

409
    else:
410
        if has_xml_lang(title_or_description_obj):
1✔
411
            langs.append(title_or_description_obj.xpath("@xml:lang", namespaces={"xml": "http://www.w3.org/XML/1998/namespace"})[0])
1✔
412
        elif has_xml_lang(iati_activity_obj):
1✔
413
            langs.append(default_lang)
1✔
414

415
    # Remove any duplicates and return
416
    return list(set(langs))
1✔
417

418

419
# Deals with elements that are in both organisation and activity files
420
class CommonSharedElements(object):
1✔
421
    blank = False
1✔
422

423
    @no_aggregation
1✔
424
    def iati_identifier(self):
425
        try:
×
426
            return self.element.find('iati-identifier').text
×
427
        except AttributeError:
×
428
            return None
×
429

430
    @returns_numberdict
1✔
431
    def reporting_orgs(self):
432
        try:
×
433
            return {self.element.find('reporting-org').attrib.get('ref'): 1}
×
434
        except AttributeError:
×
435
            return {'null': 1}
×
436

437
    @returns_numberdict
1✔
438
    def participating_orgs(self):
439
        return dict([(x.attrib.get('ref'), 1) for x in self.element.findall('participating-org')])
×
440

441
    @returns_numberdictdict
1✔
442
    def _participating_orgs_text(self):
443
        return dict([(x.attrib.get('ref'), {x.text: 1}) for x in self.element.findall('participating-org')])
×
444

445
    @returns_numberdictdict
1✔
446
    def _participating_orgs_by_role(self):
447
        return dict([(x.attrib.get('role'), {x.attrib.get('ref'): 1}) for x in self.element.findall('participating-org')])
×
448

449
    @returns_numberdict
1✔
450
    def _element_versions(self):
451
        return {self.element.attrib.get('version'): 1}
×
452

453
    @returns_numberdict
1✔
454
    @memoize
1✔
455
    def _major_version(self):
456
        if self._version().startswith('2.'):
1✔
457
            return '2'
1✔
458
        else:
459
            return '1'
1✔
460

461
    @returns_numberdict
1✔
462
    @memoize
1✔
463
    def _version(self):
464
        allowed_versions = CODELISTS['2']['Version']
1✔
465
        parent = self.element.getparent()
1✔
466
        if parent is None:
1✔
467
            print('No parent of iati-activity, is this a test? Assuming version 1.01')
1✔
468
            return '1.01'
1✔
469
        version = parent.attrib.get('version')
1✔
470
        if version and version in allowed_versions:
1✔
471
            return version
1✔
472
        else:
473
            return '1.01'
1✔
474

475
    @returns_numberdict
1✔
476
    def _ruleset_passes(self):
477
        out = {}
×
478
        for ruleset_name in ['standard']:
×
479
            ruleset = json.load(open('helpers/rulesets/{0}.json'.format(ruleset_name)), object_pairs_hook=OrderedDict)
×
480
            out[ruleset_name] = int(iatirulesets.test_ruleset_subelement(ruleset, self.element))
×
481
        return out
×
482

483

484
class ActivityStats(CommonSharedElements):
1✔
485
    """ Stats calculated on a single iati-activity. """
486
    element = None
1✔
487
    blank = False
1✔
488
    strict = False  # (Setting this to true will ignore values that don't follow the schema)
1✔
489
    context = ''
1✔
490
    comprehensiveness_current_activity_status = None
1✔
491
    now = datetime.now()  # TODO Add option to set this to date of git commit
1✔
492

493
    @returns_numberdict
1✔
494
    def iati_identifiers(self):
495
        try:
×
496
            return {self.iati_identifier(): 1}
×
497
        except AttributeError:
×
498
            return None
×
499

500
    @returns_number
1✔
501
    def activities(self):
502
        return 1
1✔
503

504
    @returns_numberdict
1✔
505
    def hierarchies(self):
506
        return {self.element.attrib.get('hierarchy'): 1}
1✔
507

508
    def _budget_not_provided(self):
1✔
509
        if self.element.attrib.get('budget-not-provided') is not None:
1✔
510
            return int(self.element.attrib.get('budget-not-provided'))
1✔
511
        else:
512
            return None
1✔
513

514
    def by_hierarchy(self):
1✔
515
        out = {}
1✔
516
        for stat in ['activities', 'elements', 'elements_total',
1✔
517
                     'forwardlooking_currency_year', 'forwardlooking_activities_current', 'forwardlooking_activities_with_budgets', 'forwardlooking_activities_with_budget_not_provided',
518
                     'comprehensiveness', 'comprehensiveness_with_validation', 'comprehensiveness_denominators', 'comprehensiveness_denominator_default'
519
                     ]:
520
            out[stat] = copy.deepcopy(getattr(self, stat)())
1✔
521
        if self.blank:
1✔
522
            return defaultdict(lambda: out)
×
523
        else:
524
            hierarchy = self.element.attrib.get('hierarchy')
1✔
525
            return {('1' if hierarchy is None else hierarchy): out}
1✔
526

527
    @returns_numberdict
1✔
528
    def _currencies(self):
529
        currencies = [x.find('value').get('currency') for x in self.element.findall('transaction') if x.find('value') is not None]
×
530
        currencies = [c if c else self.element.get('default-currency') for c in currencies]
×
531
        return dict((c, 1) for c in currencies)
×
532

533
    def _planned_start_code(self):
1✔
534
        if self._major_version() == '1':
1✔
535
            return 'start-planned'
1✔
536
        else:
537
            return '1'
1✔
538

539
    def _actual_start_code(self):
1✔
540
        if self._major_version() == '1':
1✔
541
            return 'start-actual'
1✔
542
        else:
543
            return '2'
1✔
544

545
    def _planned_end_code(self):
1✔
546
        if self._major_version() == '1':
1✔
547
            return 'end-planned'
1✔
548
        else:
549
            return '3'
1✔
550

551
    def _actual_end_code(self):
1✔
552
        if self._major_version() == '1':
1✔
553
            return 'end-actual'
1✔
554
        else:
555
            return '4'
1✔
556

557
    def _incoming_funds_code(self):
1✔
558
        if self._major_version() == '1':
1✔
559
            return 'IF'
1✔
560
        else:
561
            return '1'
1✔
562

563
    def _commitment_code(self):
1✔
564
        if self._major_version() == '1':
1✔
565
            return 'C'
1✔
566
        else:
567
            return '2'
1✔
568

569
    def _disbursement_code(self):
1✔
570
        if self._major_version() == '1':
1✔
571
            return 'D'
1✔
572
        else:
573
            return '3'
1✔
574

575
    def _expenditure_code(self):
1✔
576
        if self._major_version() == '1':
1✔
577
            return 'E'
1✔
578
        else:
579
            return '4'
1✔
580

581
    def _dac_5_code(self):
1✔
582
        if self._major_version() == '1':
1✔
583
            return 'DAC'
1✔
584
        else:
585
            return '1'
1✔
586

587
    def _dac_3_code(self):
1✔
588
        if self._major_version() == '1':
1✔
589
            return 'DAC-3'
1✔
590
        else:
591
            return '2'
1✔
592

593
    def _funding_code(self):
1✔
594
        if self._major_version() == '1':
1✔
595
            return 'Funding'
1✔
596
        else:
597
            return '1'
1✔
598

599
    def _OrganisationRole_Extending_code(self):
1✔
600
        if self._major_version() == '1':
1✔
601
            return 'Extending'
1✔
602
        else:
603
            return '3'
1✔
604

605
    def _OrganisationRole_Implementing_code(self):
1✔
606
        if self._major_version() == '1':
×
607
            return 'Implementing'
×
608
        else:
609
            return '4'
×
610

611
    def __get_start_year(self):
1✔
612
        activity_date = self.element.find("activity-date[@type='{}']".format(self._actual_start_code()))
×
613
        if activity_date is None:
×
614
            activity_date = self.element.find("activity-date[@type='{}']".format(self._planned_start_code()))
×
615
        if activity_date is not None and activity_date.get('iso-date'):
×
616
            try:
×
617
                act_date = datetime.strptime(activity_date.get('iso-date').strip('Z'), "%Y-%m-%d")
×
618
                return int(act_date.year)
×
619
            except ValueError as e:
×
620
                debug(self, e)
×
621
            except AttributeError as e:
×
622
                debug(self, e)
×
623

624
    @returns_numberdict
1✔
625
    def _activities_per_year(self):
626
        return {self.__get_start_year(): 1}
×
627

628
    @returns_numberdict
1✔
629
    @memoize
1✔
630
    def elements(self):
631
        return element_to_count_dict(self.element, 'iati-activity', {})
1✔
632

633
    @returns_numberdict
1✔
634
    @memoize
1✔
635
    def elements_total(self):
636
        return element_to_count_dict(self.element, 'iati-activity', defaultdict(int), True)
1✔
637

638
    @returns_numberdictdict
1✔
639
    def boolean_values(self):
640
        out = defaultdict(lambda: defaultdict(int))
×
641
        for path in [
×
642
                'conditions/@attached',
643
                'crs-add/aidtype-flag/@significance',
644
                'crs-add/other-flags/@significance',
645
                'fss/@priority',
646
                '@humanitarian',
647
                'reporting-org/@secondary-reporter',
648
                'result/indicator/@ascending',
649
                'result/@aggregation-status',
650
                'transaction/@humanitarian'
651
        ]:
652
            for value in self.element.xpath(path):
×
653
                out[path][value] += 1
×
654
        return out
×
655

656
    @returns_numberdict
1✔
657
    def _provider_org(self):
658
        out = defaultdict(int)
×
659
        for transaction in self.element.findall('transaction'):
×
660
            provider_org = transaction.find('provider-org')
×
661
            if provider_org is not None:
×
662
                out[provider_org.attrib.get('ref')] += 1
×
663
        return out
×
664

665
    @returns_numberdict
1✔
666
    def _receiver_org(self):
667
        out = defaultdict(int)
×
668
        for transaction in self.element.findall('transaction'):
×
669
            receiver_org = transaction.find('receiver-org')
×
670
            if receiver_org is not None:
×
671
                out[receiver_org.attrib.get('ref')] += 1
×
672
        return out
×
673

674
    @returns_numberdict
1✔
675
    def _transactions_incoming_funds(self):
676
        """
677
        Counts the number of activities which contain at least one transaction with incoming funds.
678
        Also counts the number of transactions where the type is incoming funds
679
        """
680
        # Set default output
681
        out = defaultdict(int)
×
682

683
        # Loop over each tranaction
684
        for transaction in self.element.findall('transaction'):
×
685
            # If the transaction-type element has a code of 'IF' (v1) or 1 (v2), increment the output counter
686
            if transaction.xpath('transaction-type/@code="{}"'.format(self._incoming_funds_code())):
×
687
                out['transactions_with_incoming_funds'] += 1
×
688

689
        # If there is at least one transaction within this activity with an incoming funds transaction, then increment the number of activities with incoming funds
690
        if out['transactions_with_incoming_funds'] > 0:
×
691
            out['activities_with_incoming_funds'] += 1
×
692

693
        return out
×
694

695
    @returns_numberdict
1✔
696
    def _transaction_timing(self):
697
        today = self.now.date()
×
698

699
        def months_ago(n):
×
700
            self.now.date() - timedelta(days=n * 30)
×
701
        out = {30: 0, 60: 0, 90: 0, 180: 0, 360: 0}
×
702

703
        for transaction in self.element.findall('transaction'):
×
704
            date = transaction_date(transaction)
×
705
            if date:
×
706
                days = (today - date).days
×
707
                if days < -1:
×
708
                    continue
×
709
                for k in sorted(out.keys()):
×
710
                    if days < k:
×
711
                        out[k] += 1
×
712
        return out
×
713

714
    @returns_numberdict
1✔
715
    def _transaction_months(self):
716
        out = defaultdict(int)
×
717
        for transaction in self.element.findall('transaction'):
×
718
            date = transaction_date(transaction)
×
719
            if date:
×
720
                out[date.month] += 1
×
721
        return out
×
722

723
    @returns_numberdict
1✔
724
    def transaction_months_with_year(self):
725
        out = defaultdict(int)
×
726
        for transaction in self.element.findall('transaction'):
×
727
            date = transaction_date(transaction)
×
728
            if date:
×
729
                out['{}-{}'.format(date.year, str(date.month).zfill(2))] += 1
×
730
        return out
×
731

732
    @returns_numberdict
1✔
733
    def _budget_lengths(self):
734
        out = defaultdict(int)
×
735
        for budget in self.element.findall('budget'):
×
736
            period_start = iso_date(budget.find('period-start'))
×
737
            period_end = iso_date(budget.find('period-end'))
×
738
            if period_start and period_end:
×
739
                out[(period_end - period_start).days] += 1
×
740
        return out
×
741

742
    def _transaction_year(self, transaction):
1✔
743
        t_date = transaction_date(transaction)
×
744
        return t_date.year if t_date else None
×
745

746
    def __spend_currency_year(self, transactions):
1✔
747
        out = defaultdict(lambda: defaultdict(Decimal))
×
748
        for transaction in transactions:
×
749
            value = transaction.find('value')
×
750
            if (transaction.find('transaction-type') is not None and transaction.find('transaction-type').attrib.get('code') in [self._disbursement_code(), self._expenditure_code()]):
×
751
                # Set transaction_value if a value exists for this transaction. Else set to 0
752
                transaction_value = 0 if value is None else Decimal(value.text)
×
753

754
                out[self._transaction_year(transaction)][get_currency(self, transaction)] += transaction_value
×
755
        return out
×
756

757
    @returns_numberdictdict
1✔
758
    def _spend_currency_year(self):
759
        return self.__spend_currency_year(self.element.findall('transaction'))
×
760

761
    def _is_secondary_reported(self):
1✔
762
        """Tests if this activity has been secondary reported. Test based on if the
763
           secondary-reporter flag is set.
764
        Input -- None
765
        Output:
766
          True -- Secondary-reporter flag set
767
          False -- Secondary-reporter flag not set, or evaulates to False
768
        """
769
        reporting_org_el = self.element.find('reporting-org')
×
770
        if reporting_org_el is None:
×
771
            return False
×
772
        secondary = reporting_org_el.attrib.get('secondary-reporter')
×
773
        if secondary in ['1', 'true']:
×
774
            return True
×
775
        return False
×
776

777
    @returns_dict
1✔
778
    def activities_secondary_reported(self):
779
        if self._is_secondary_reported():
×
780
            return {self.iati_identifier(): 1}
×
781
        else:
782
            return {}
×
783

784
    @returns_numberdictdict
1✔
785
    def forwardlooking_currency_year(self):
786
        # Note this is not currently displayed on Analytics
787
        # As the forwardlooking page now only displays counts,
788
        # not the sums that this function calculates.
789
        out = defaultdict(lambda: defaultdict(Decimal))
1✔
790
        budgets = self.element.findall('budget')
1✔
791
        for budget in budgets:
1✔
792
            value = budget.find('value')
×
793

794
            # Set budget_value if a value exists for this budget. Else set to 0
795
            budget_value = 0 if value is None else Decimal(value.text)
×
796

797
            out[budget_year(budget)][get_currency(self, budget)] += budget_value
×
798
        return out
1✔
799

800
    def _get_end_date(self):
1✔
801
        """Gets the end date for the activity. An 'actual end date' is preferred
802
           over a 'planned end date'
803
           Inputs: None
804
           Output: a date object, or None if no value date found
805
        """
806
        # Get enddate. An 'actual end date' is preferred over a 'planned end date'
807
        end_date_list = (self.element.xpath('activity-date[@type="{}"]'.format(self._actual_end_code())) or self.element.xpath('activity-date[@type="{}"]'.format(self._planned_end_code())))
1✔
808

809
        # If there is a date, convert to a date object
810
        if end_date_list:
1✔
811
            return iso_date(end_date_list[0])
1✔
812
        else:
813
            return None
1✔
814

815
    def _forwardlooking_is_current(self, year):
1✔
816
        """Tests if an activity contains i) at least one (actual or planned) end year which is greater
817
           or equal to the year passed to this function, or ii) no (actual or planned) end years at all.
818
           Returns: True or False
819
        """
820
        # Get list of years for each of the planned-end and actual-end dates
821
        activity_end_years = [
1✔
822
            iso_date(x).year
823
            for x in self.element.xpath('activity-date[@type="{}" or @type="{}"]'.format(self._planned_end_code(), self._actual_end_code()))
824
            if iso_date(x)
825
        ]
826
        # Return boolean. True if activity_end_years is empty, or at least one of the actual/planned
827
        # end years is greater or equal to the year passed to this function
828
        return (not activity_end_years) or any(activity_end_year >= year for activity_end_year in activity_end_years)
1✔
829

830
    def _get_ratio_commitments_disbursements(self, year):
1✔
831
        """ Calculates the ratio of commitments vs total amount disbursed or expended in or before the
832
            input year. Values are converted to USD to improve comparability.
833
            Input:
834
              year -- The point in time to aggregate expenditure and disbursements
835
            Returns:
836
              Float: 0 represents no commitments disbursed, 1 represents all commitments disbursed.
837
        """
838

839
        # Compute the sum of all commitments
840

841
        # Build a list of tuples, each tuple contains: (currency, value, date)
842
        commitment_transactions = [(
1✔
843
            get_currency(self, transaction),
844
            transaction.xpath('value/text()')[0] if transaction.xpath('value/text()') else None,
845
            transaction_date(transaction)
846
        ) for transaction in self.element.xpath('transaction[transaction-type/@code="{}"]'.format(self._commitment_code()))]
847

848
        # Convert transaction values to USD and aggregate
849
        commitment_transactions_usd_total = sum([get_USD_value(x[0], x[1], x[2].year)
1✔
850
                                                 for x in commitment_transactions if None not in x])
851

852
        # Compute the sum of all disbursements and expenditures up to and including the inputted year
853
        # Build a list of tuples, each tuple contains: (currency, value, date)
854
        exp_disb_transactions = [(
1✔
855
            get_currency(self, transaction),
856
            transaction.xpath('value/text()')[0] if transaction.xpath('value/text()') else None,
857
            transaction_date(transaction)
858
        ) for transaction in self.element.xpath('transaction[transaction-type/@code="{}" or transaction-type/@code="{}"]'.format(self._disbursement_code(), self._expenditure_code()))]
859

860
        # If the transaction date this year or older, convert transaction values to USD and aggregate
861
        exp_disb_transactions_usd_total = sum([get_USD_value(x[0], x[1], x[2].year)
1✔
862
                                              for x in exp_disb_transactions if None not in x and x[2].year <= int(year)])
863

864
        if commitment_transactions_usd_total > 0:
1✔
865
            return convert_to_float(exp_disb_transactions_usd_total) / convert_to_float(commitment_transactions_usd_total)
1✔
866
        else:
867
            return None
1✔
868

869
    def _forwardlooking_exclude_in_calculations(self, year=date.today().year, date_code_runs=None):
1✔
870
        """ Tests if an activity should be excluded from the forward looking calculations.
871
            Activities are excluded if:
872
              i) They end within six months from date_code_runs OR
873
              ii) At least 90% of the commitment transactions has been disbursed or expended
874
                  within or before the input year
875

876
            This arises from:
877
            https://github.com/IATI/IATI-Dashboard/issues/388
878
            https://github.com/IATI/IATI-Dashboard/issues/389
879

880
            Input:
881
              year -- The point in time to test the above criteria against
882
              date_code_runs -- a date object for when this code is run
883
            Returns: 0 if not excluded
884
                     >0 if excluded
885
        """
886

887
        # Set date_code_runs. Defaults to self.now (as a date object)
888
        date_code_runs = date_code_runs if date_code_runs else self.now.date()
1✔
889

890
        # If this activity has an end date, check that it will not end within the next six
891
        # months from date_code_runs
892
        if self._get_end_date():
1✔
893
            if (date_code_runs + relativedelta(months=+6)) > self._get_end_date():
1✔
894
                return 1
1✔
895

896
        if self._get_ratio_commitments_disbursements(year) is not None and self._get_ratio_commitments_disbursements(year) >= 0.9:
1✔
897
            return 2
×
898
        else:
899
            return 0
1✔
900

901
    def _is_donor_publisher(self):
1✔
902
        """Returns True if this activity is deemed to be reported by a donor publisher.
903
           Methodology descibed in https://github.com/IATI/IATI-Dashboard/issues/377
904
        """
905
        # If there is no 'reporting-org/@ref' element, return False to avoid a 'list index out of range'
906
        # error in the statement that follows
907
        if len(self.element.xpath('reporting-org/@ref')) < 1:
1✔
908
            return False
1✔
909

910
        return (
1✔
911
            (
912
                self.element.xpath('reporting-org/@ref')[0] in self.element.xpath("participating-org[@role='{}']/@ref|participating-org[@role='{}']/@ref".format(
913
                    self._funding_code(),
914
                    self._OrganisationRole_Extending_code()))
915
            ) and (
916
                self.element.xpath('reporting-org/@ref')[0] not in self.element.xpath("participating-org[@role='{}']/@ref".format(
917
                    self._OrganisationRole_Implementing_code())
918
                )
919
            )
920
        )
921

922
    @returns_dict
1✔
923
    def _forwardlooking_excluded_activities(self):
924
        """Outputs whether this activity is excluded for the purposes of forwardlooking calculations
925
           Returns iati-identifier and...: 0 if not excluded
926
                                           1 if excluded
927
        """
928
        # Set the current year. Defaults to self.now (as a date object)
929
        this_year = date.today().year
×
930

931
        # Retreive a dictionary with the activity identifier and the result for this and the next two years
932
        return {self.element.find('iati-identifier').text: {year: int(self._forwardlooking_exclude_in_calculations(year))
×
933
                for year in range(this_year, this_year + 3)}}
934

935
    @returns_numberdict
1✔
936
    def forwardlooking_activities_current(self, date_code_runs=None):
1✔
937
        """
938
        The number of current and non-excluded activities for this year and the following 2 years.
939

940
        Current activities: http://support.iatistandard.org/entries/52291985-Forward-looking-Activity-level-budgets-numerator
941

942
        Note activities excluded according if they meet the logic in _forwardlooking_exclude_in_calculations()
943

944
        Note: this is a different definition of 'current' to the older annual
945
        report stats in this file, so does not re-use those functions.
946

947
        Input:
948
          date_code_runs -- a date object for when this code is run
949
        Returns:
950
          dictionary containing years with binary value if this activity is current
951

952
        """
953

954
        # Set date_code_runs. Defaults to self.now (as a date object)
955
        date_code_runs = date_code_runs if date_code_runs else self.now.date()
1✔
956

957
        this_year = date_code_runs.year
1✔
958
        return {year: int(self._forwardlooking_is_current(year) and not bool(self._forwardlooking_exclude_in_calculations(year=year, date_code_runs=date_code_runs)))
1✔
959
                for year in range(this_year, this_year + 3)}
960

961
    @returns_numberdict
1✔
962
    def forwardlooking_activities_with_budgets(self, date_code_runs=None):
1✔
963
        """
964
        The number of current activities with budgets for this year and the following 2 years.
965

966
        http://support.iatistandard.org/entries/52292065-Forward-looking-Activity-level-budgets-denominator
967

968
        Note activities excluded according if they meet the logic in _forwardlooking_exclude_in_calculations()
969

970
        Input:
971
          date_code_runs -- a date object for when this code is run
972
        Returns:
973
          dictionary containing years with binary value if this activity is current and has a budget for the given year
974
        """
975
        # Set date_code_runs. Defaults to self.now (as a date object)
976
        date_code_runs = date_code_runs if date_code_runs else self.now.date()
1✔
977

978
        this_year = int(date_code_runs.year)
1✔
979
        budget_years = ([budget_year(budget) for budget in self.element.findall('budget')])
1✔
980
        return {year: int(self._forwardlooking_is_current(year) and year in budget_years and not bool(self._forwardlooking_exclude_in_calculations(year=year, date_code_runs=date_code_runs)))
1✔
981
                for year in range(this_year, this_year + 3)}
982

983
    @returns_numberdict
1✔
984
    def forwardlooking_activities_with_budget_not_provided(self, date_code_runs=None):
1✔
985
        """
986
        Number of activities with the budget_not_provided attribute for this year and the following 2 years.
987

988
        Note activities excluded according if they meet the logic in _forwardlooking_exclude_in_calculations()
989

990
        Input:
991
          date_code_runs -- a date object for when this code is run
992
        Returns:
993
          dictionary containing years with binary value if this activity is current and has the budget_not_provided attribute
994
        """
995
        date_code_runs = date_code_runs if date_code_runs else self.now.date()
1✔
996
        this_year = int(date_code_runs.year)
1✔
997
        bnp = self._budget_not_provided() is not None
1✔
998
        return {year: int(self._forwardlooking_is_current(year) and bnp > 0 and not bool(self._forwardlooking_exclude_in_calculations(year=year, date_code_runs=date_code_runs)))
1✔
999
                for year in range(this_year, this_year + 3)}
1000

1001
    @memoize
1✔
1002
    def _comprehensiveness_is_current(self):
1003
        """
1004
        Tests if this activity should be considered as part of the comprehensiveness calculations.
1005
        Logic is based on the activity status code and end dates.
1006
        Returns: True or False
1007
        """
1008

1009
        # Get the activity-code value for this activity
1010
        activity_status_code = self.element.xpath('activity-status/@code')
1✔
1011

1012
        # Get the end dates for this activity as lists
1013
        activity_planned_end_dates = [iso_date(x) for x in self.element.xpath('activity-date[@type="{}"]'.format(self._planned_end_code())) if iso_date(x)]
1✔
1014
        activity_actual_end_dates = [iso_date(x) for x in self.element.xpath('activity-date[@type="{}"]'.format(self._actual_end_code())) if iso_date(x)]
1✔
1015

1016
        # If there is no planned end date AND activity-status/@code is 2 (implementing) or 4 (post-completion), then this is a current activity
1017
        if not activity_planned_end_dates and activity_status_code:
1✔
1018
            if activity_status_code[0] == '2' or activity_status_code[0] == '4':
1✔
1019
                self.comprehensiveness_current_activity_status = 1
1✔
1020
                return True
1✔
1021

1022
        # If the actual end date is within the last year, then this is a current activity
1023
        for actual_end_date in activity_actual_end_dates:
1✔
1024
            if (actual_end_date >= add_years(self.today, -1)) and (actual_end_date <= self.today):
1✔
1025
                self.comprehensiveness_current_activity_status = 2
1✔
1026
                return True
1✔
1027

1028
        # If the planned end date is greater than today, then this is a current activity
1029
        for planned_end_date in activity_planned_end_dates:
1✔
1030
            if planned_end_date >= self.today:
1✔
1031
                self.comprehensiveness_current_activity_status = 3
1✔
1032
                return True
1✔
1033

1034
        # If got this far and not met one of the conditions to qualify as a current activity, return false
1035
        self.comprehensiveness_current_activity_status = 0
1✔
1036
        return False
1✔
1037

1038
    @returns_dict
1✔
1039
    def _comprehensiveness_current_activities(self):
1040
        """Outputs whether each activity is considered current for the purposes of comprehensiveness calculations"""
1041
        return {self.element.find('iati-identifier').text: self.comprehensiveness_current_activity_status}
×
1042

1043
    def _is_recipient_language_used(self):
1✔
1044
        """If there is only 1 recipient-country, test if one of the languages for that country is used
1045
           in the title and description elements.
1046
        """
1047

1048
        # Test only applies to activities where there is only 1 recipient-country
1049
        if len(self.element.findall('recipient-country')) == 1:
1✔
1050
            # Get list of languages for the recipient-country
1051
            try:
1✔
1052
                country_langs = country_lang_map[self.element.xpath('recipient-country/@code')[0]]
1✔
1053
            except (KeyError, IndexError):
1✔
1054
                country_langs = []
1✔
1055

1056
            # Get lists of the languages used in the title and descripton elements
1057
            langs_in_title = []
1✔
1058
            for title_elem in self.element.findall('title'):
1✔
1059
                langs_in_title.extend(get_language(self._major_version(), self.element, title_elem))
1✔
1060

1061
            langs_in_description = []
1✔
1062
            for descripton_elem in self.element.findall('description'):
1✔
1063
                langs_in_description.extend(get_language(self._major_version(), self.element, descripton_elem))
1✔
1064

1065
            # Test if the languages used for the title and description are in the list of country langs
1066
            if len(set(langs_in_title).intersection(country_langs)) > 0 and len(set(langs_in_description).intersection(country_langs)) > 0:
1✔
1067
                return 1
1✔
1068
            else:
1069
                return 0
1✔
1070

1071
        else:
1072
            return 0
1✔
1073

1074
    @memoize
1✔
1075
    def _comprehensiveness_bools(self):
1076

1077
        def is_text_in_element(elementName):
1✔
1078
            """ Determine if an element with the specified tagname contains any text.
1079

1080
            Keyword arguments:
1081
            elementName - The name of the element to be checked
1082

1083
            If text is present return true, else false.
1084
            """
1085

1086
            # Use xpath to return a list of found text within the specified element name
1087
            # The precise xpath needed will vary depending on the version
1088
            if self._major_version() == '2':
1✔
1089
                # In v2, textual elements must be contained within child <narrative> elements
1090
                textFound = self.element.xpath('{}/narrative/text()'.format(elementName))
1✔
1091

1092
            elif self._major_version() == '1':
1✔
1093
                # In v1, free text is allowed without the need for child elements
1094
                textFound = self.element.xpath('{}/text()'.format(elementName))
1✔
1095

1096
            else:
1097
                # This is not a valid version
1098
                textFound = []
×
1099

1100
            # Perform logic. If the list is not empty, return true. Otherwise false
1101
            return True if textFound else False
1✔
1102

1103
        return {
1✔
1104
            'version': (self.element.getparent() is not None and 'version' in self.element.getparent().attrib),
1105
            'reporting-org': (self.element.xpath('reporting-org/@ref') and is_text_in_element('reporting-org')),
1106
            'iati-identifier': self.element.xpath('iati-identifier/text()'),
1107
            'participating-org': self.element.find('participating-org') is not None,
1108
            'title': is_text_in_element('title'),
1109
            'description': is_text_in_element('description'),
1110
            'activity-status': self.element.find('activity-status') is not None,
1111
            'activity-date': self.element.find('activity-date') is not None,
1112
            'sector': self.element.find('sector') is not None or (self._major_version() != '1' and all_true_and_not_empty(
1113
                (transaction.find('sector') is not None)
1114
                for transaction in self.element.findall('transaction')
1115
            )),
1116
            'country_or_region': (
1117
                self.element.find('recipient-country') is not None or self.element.find('recipient-region') is not None or (self._major_version() != '1' and all_true_and_not_empty(
1118
                    (transaction.find('recipient-country') is not None or transaction.find('recipient-region') is not None)
1119
                    for transaction in self.element.findall('transaction')
1120
                ))),
1121
            'transaction_commitment': self.element.xpath('transaction[transaction-type/@code="{}" or transaction-type/@code="11"]'.format(self._commitment_code())),
1122
            'transaction_spend': self.element.xpath('transaction[transaction-type/@code="{}" or transaction-type/@code="{}"]'.format(self._disbursement_code(), self._expenditure_code())),
1123
            'transaction_currency': all_true_and_not_empty(x.xpath('value/@value-date') and x.xpath('../@default-currency|./value/@currency') for x in self.element.findall('transaction')),
1124
            'transaction_traceability': all_true_and_not_empty(x.xpath('provider-org/@provider-activity-id') for x in self.element.xpath('transaction[transaction-type/@code="{}" or transaction-type/@code="11" or transaction-type/@code="13"]'.format(self._incoming_funds_code()))) or self._is_donor_publisher(),
1125
            'budget': self.element.findall('budget'),
1126
            'budget_not_provided': self._budget_not_provided() is not None,
1127
            'contact-info': self.element.findall('contact-info/email'),
1128
            'location': self.element.xpath('location/point/pos|location/name|location/description|location/location-administrative'),
1129
            'location_point_pos': self.element.xpath('location/point/pos'),
1130
            'sector_dac': self._is_sector_dac(),
1131
            'capital-spend': self.element.xpath('capital-spend/@percentage'),
1132
            'document-link': self.element.findall('document-link'),
1133
            'activity-website': self.element.xpath('activity-website' if self._major_version() == '1' else 'document-link[category/@code="A12"]'),
1134
            'recipient_language': self._is_recipient_language_used(),
1135
            'conditions_attached': self.element.xpath('conditions/@attached'),
1136
            'result_indicator': self.element.xpath('result/indicator'),
1137
            'aid_type': (
1138
                all_true_and_not_empty(self.element.xpath('default-aid-type/@code')) or all_true_and_not_empty([transaction.xpath('aid-type/@code') for transaction in self.element.xpath('transaction')])
1139
            )
1140
            # Alternative: all(map(all_true_and_not_empty, [transaction.xpath('aid-type/@code') for transaction in self.element.xpath('transaction')]))
1141
        }
1142

1143
    def _is_sector_dac(self):
1✔
1144
        """Determine whether an activity has comprehensive DAC sectors against the validation methodology."""
1145
        sector_dac_activity_level = self.element.xpath('sector[@vocabulary="{}" or @vocabulary="{}" or not(@vocabulary)]'.format(self._dac_5_code(), self._dac_3_code()))
1✔
1146

1147
        if self._major_version() != '1':
1✔
1148
            sector_dac_transaction_level = [transaction.xpath('sector[@vocabulary="{}" or @vocabulary="{}" or not(@vocabulary)]'.format(self._dac_5_code(), self._dac_3_code())) for transaction in self.element.xpath('transaction')]
1✔
1149
            all_transactions_have_dac_sector_codes = all_true_and_not_empty(sector_dac_transaction_level)
1✔
1150
        else:
1151
            all_transactions_have_dac_sector_codes = False
1✔
1152

1153
        return sector_dac_activity_level or all_transactions_have_dac_sector_codes
1✔
1154

1155
    def _comprehensiveness_with_validation_bools(self):
1✔
1156
        def element_ref(element_obj):
1✔
1157
            """Get the ref attribute of a given element.
1158

1159
            Returns:
1160
              Value in the 'ref' attribute or None if none found
1161
            """
1162
            return element_obj.attrib.get('ref') if element_obj is not None else None
1✔
1163

1164
        def decimal_or_zero(value):
1✔
1165
            try:
1✔
1166
                return Decimal(value)
1✔
1167
            except TypeError:
1✔
1168
                return 0
1✔
1169

1170
        def empty_or_percentage_sum_is_100(path, by_vocab=False):
1✔
1171
            elements = self.element.xpath(path)
1✔
1172
            if not elements:
1✔
1173
                return True
1✔
1174
            else:
1175
                elements_by_vocab = defaultdict(list)
1✔
1176
                if by_vocab:
1✔
1177
                    for element in elements:
1✔
1178
                        elements_by_vocab[element.attrib.get('vocabulary')].append(element)
1✔
1179
                    return all(
1✔
1180
                        len(es) == 1 or sum(decimal_or_zero(x.attrib.get('percentage')) for x in es) == 100
1181
                        for es in elements_by_vocab.values())
1182
                else:
1183
                    return len(elements) == 1 or sum(decimal_or_zero(x.attrib.get('percentage')) for x in elements) == 100
1✔
1184

1185
        bools = copy.copy(self._comprehensiveness_bools())
1✔
1186
        reporting_org_ref = element_ref(self.element.find('reporting-org'))
1✔
1187
        previous_reporting_org_refs = [element_ref(x) for x in self.element.xpath('other-identifier[@type="B1"]') if element_ref(x) is not None]
1✔
1188

1189
        bools.update({
1✔
1190
            'version': bools['version'] and self.element.getparent().attrib['version'] in CODELISTS[self._major_version()]['Version'],
1191
            'iati-identifier': (
1192
                bools['iati-identifier'] and (
1193
                    # Give v1.xx data an automatic pass on this sub condition: https://github.com/IATI/IATI-Dashboard/issues/399
1194
                    (reporting_org_ref and self.element.find('iati-identifier').text.startswith(reporting_org_ref)) or \
1195
                    any([self.element.find('iati-identifier').text.startswith(x) for x in previous_reporting_org_refs])
1196
                    if self._major_version() != '1' else True
1197
                )),
1198
            'participating-org': bools['participating-org'] and self._funding_code() in self.element.xpath('participating-org/@role'),
1199
            'activity-status': bools['activity-status'] and all_true_and_not_empty(x in CODELISTS[self._major_version()]['ActivityStatus'] for x in self.element.xpath('activity-status/@code')),
1200
            'activity-date': (
1201
                bools['activity-date'] and \
1202
                self.element.xpath('activity-date[@type="{}" or @type="{}"]'.format(self._planned_start_code(), self._actual_start_code())) and \
1203
                all_true_and_not_empty(map(valid_date, self.element.findall('activity-date')))
1204
            ),
1205
            'sector': (
1206
                bools['sector'] and \
1207
                empty_or_percentage_sum_is_100('sector', by_vocab=True)),
1208
            'country_or_region': (
1209
                bools['country_or_region'] and \
1210
                empty_or_percentage_sum_is_100('recipient-country|recipient-region')),
1211
            'transaction_commitment': (
1212
                bools['transaction_commitment'] and \
1213
                all([valid_value(x.find('value')) for x in bools['transaction_commitment']]) and \
1214
                all_true_and_not_empty(any(valid_date(x) for x in t.xpath('transaction-date|value')) for t in bools['transaction_commitment'])
1215
            ),
1216
            'transaction_spend': (
1217
                bools['transaction_spend'] and \
1218
                all([valid_value(x.find('value')) for x in bools['transaction_spend']]) and \
1219
                all_true_and_not_empty(any(valid_date(x) for x in t.xpath('transaction-date|value')) for t in bools['transaction_spend'])
1220
            ),
1221
            'transaction_currency': all(
1222
                all(map(valid_date, t.findall('value'))) and \
1223
                all(x in CODELISTS[self._major_version()]['Currency'] for x in t.xpath('../@default-currency|./value/@currency')) for t in self.element.findall('transaction')
1224
            ),
1225
            'budget': (
1226
                bools['budget'] and \
1227
                all(
1228
                    valid_date(budget.find('period-start')) and \
1229
                    valid_date(budget.find('period-end')) and \
1230
                    valid_date(budget.find('value')) and \
1231
                    valid_value(budget.find('value'))
1232
                    for budget in bools['budget'])),
1233
            'budget_not_provided': (
1234
                bools['budget_not_provided'] and \
1235
                str(self._budget_not_provided()) in CODELISTS[self._major_version()]['BudgetNotProvided']),
1236
            'location_point_pos': all_true_and_not_empty(
1237
                valid_coords(x.text) for x in bools['location_point_pos']),
1238
            'sector_dac': (
1239
                bools['sector_dac'] and \
1240
                all(x.attrib.get('code') in CODELISTS[self._major_version()]['Sector'] for x in self.element.xpath('sector[@vocabulary="{}" or not(@vocabulary)]'.format(self._dac_5_code()))) and \
1241
                all(x.attrib.get('code') in CODELISTS[self._major_version()]['SectorCategory'] for x in self.element.xpath('sector[@vocabulary="{}"]'.format(self._dac_3_code())))
1242
            ),
1243
            'document-link': all_true_and_not_empty(
1244
                valid_url(x) and x.find('category') is not None and x.find('category').attrib.get('code') in CODELISTS[self._major_version()]['DocumentCategory'] for x in bools['document-link']),
1245
            'activity-website': all_true_and_not_empty(map(valid_url, bools['activity-website'])),
1246
            'aid_type': (
1247
                # i) Value in default-aid-type/@code is found in the codelist
1248
                # Or ii) Each transaction has a aid-type/@code which is found in the codelist
1249
                bools['aid_type'] and \
1250
                (all_true_and_not_empty([code in CODELISTS[self._major_version()]['AidType'] for code in self.element.xpath('default-aid-type/@code')]) or \
1251
                 all_true_and_not_empty(
1252
                    [set(x).intersection(CODELISTS[self._major_version()]['AidType'])
1253
                     for x in [transaction.xpath('aid-type/@code') for transaction in self.element.xpath('transaction')]]
1254
                ))
1255
            )
1256
        })
1257
        return bools
1✔
1258

1259
    @returns_numberdict
1✔
1260
    def comprehensiveness(self):
1261
        if self._comprehensiveness_is_current():
1✔
1262
            return {k: (1 if v and (k not in self.comprehensiveness_denominators() or self.comprehensiveness_denominators()[k]) else 0) for k, v in self._comprehensiveness_bools().items()}
1✔
1263
        else:
1264
            return {}
1✔
1265

1266
    @returns_numberdict
1✔
1267
    def comprehensiveness_with_validation(self):
1268
        if self._comprehensiveness_is_current():
1✔
1269
            return {k: (1 if v and (k not in self.comprehensiveness_denominators() or self.comprehensiveness_denominators()[k]) else 0) for k, v in self._comprehensiveness_with_validation_bools().items()}
1✔
1270
        else:
1271
            return {}
1✔
1272

1273
    @returns_number
1✔
1274
    def comprehensiveness_denominator_default(self):
1275
        return 1 if self._comprehensiveness_is_current() else 0
1✔
1276

1277
    @returns_numberdict
1✔
1278
    def comprehensiveness_denominators(self):
1279
        if self._comprehensiveness_is_current():
1✔
1280
            dates = self.element.xpath('activity-date[@type="{}"]'.format(self._actual_start_code())) + self.element.xpath('activity-date[@type="{}"]'.format(self._planned_start_code()))
1✔
1281
            if dates:
1✔
1282
                start_date = iso_date(dates[0])
1✔
1283
            else:
1284
                start_date = None
1✔
1285
            return {
1✔
1286
                'recipient_language': 1 if len(self.element.findall('recipient-country')) == 1 else 0,
1287
                'transaction_spend': 1 if start_date and start_date < self.today and (self.today - start_date) > timedelta(days=365) else 0,
1288
                'transaction_traceability': 1 if (self.element.xpath('transaction[transaction-type/@code="{}" or transaction-type/@code="11" or transaction-type/@code="13"]'.format(self._incoming_funds_code()))) or self._is_donor_publisher() else 0,
1289
            }
1290
        else:
1291
            return {
1✔
1292
                'recipient_language': 0,
1293
                'transaction_spend': 0,
1294
                'transaction_traceability': 0
1295
            }
1296

1297
    @returns_numberdict
1✔
1298
    def humanitarian(self):
1299
        humanitarian_sectors_dac_5_digit = ['72010', '72011', '72012', '72040', '72050', '73010', '74010', '74020']
1✔
1300
        humanitarian_sectors_dac_3_digit = ['720', '730', '740']
1✔
1301

1302
        # logic around use of the @humanitarian attribute
1303
        is_humanitarian_by_attrib_activity = 1 if ('humanitarian' in self.element.attrib) and (self.element.attrib['humanitarian'] in ['1', 'true']) else 0
1✔
1304
        is_not_humanitarian_by_attrib_activity = 1 if ('humanitarian' in self.element.attrib) and (self.element.attrib['humanitarian'] in ['0', 'false']) else 0
1✔
1305
        is_humanitarian_by_attrib_transaction = 1 if set(self.element.xpath('transaction/@humanitarian')).intersection(['1', 'true']) else 0
1✔
1306
        is_humanitarian_by_attrib = (self._version() in ['2.02', '2.03']) and (is_humanitarian_by_attrib_activity or (is_humanitarian_by_attrib_transaction and not is_not_humanitarian_by_attrib_activity))
1✔
1307

1308
        # logic around DAC sector codes deemed to be humanitarian
1309
        is_humanitarian_by_sector_5_digit_activity = 1 if set(self.element.xpath('sector[@vocabulary="{0}" or not(@vocabulary)]/@code'.format(self._dac_5_code()))).intersection(humanitarian_sectors_dac_5_digit) else 0
1✔
1310
        is_humanitarian_by_sector_5_digit_transaction = 1 if set(self.element.xpath('transaction[not(@humanitarian="0" or @humanitarian="false")]/sector[@vocabulary="{0}" or not(@vocabulary)]/@code'.format(self._dac_5_code()))).intersection(humanitarian_sectors_dac_5_digit) else 0
1✔
1311
        is_humanitarian_by_sector_3_digit_activity = 1 if set(self.element.xpath('sector[@vocabulary="{0}"]/@code'.format(self._dac_3_code()))).intersection(humanitarian_sectors_dac_3_digit) else 0
1✔
1312
        is_humanitarian_by_sector_3_digit_transaction = 1 if set(self.element.xpath('transaction[not(@humanitarian="0" or @humanitarian="false")]/sector[@vocabulary="{0}"]/@code'.format(self._dac_3_code()))).intersection(humanitarian_sectors_dac_3_digit) else 0
1✔
1313
        # helper variables to help make logic easier to read
1314
        is_humanitarian_by_sector_activity = is_humanitarian_by_sector_5_digit_activity or is_humanitarian_by_sector_3_digit_activity
1✔
1315
        is_humanitarian_by_sector_transaction = is_humanitarian_by_sector_5_digit_transaction or is_humanitarian_by_sector_3_digit_transaction
1✔
1316
        is_humanitarian_by_sector = is_humanitarian_by_sector_activity or (is_humanitarian_by_sector_transaction and (self._major_version() in ['2']))
1✔
1317

1318
        # combine the various ways in which an activity may be humanitarian
1319
        is_humanitarian = 1 if (is_humanitarian_by_attrib or is_humanitarian_by_sector) else 0
1✔
1320
        # deal with some edge cases that have veto
1321
        if is_not_humanitarian_by_attrib_activity:
1✔
1322
            is_humanitarian = 0
1✔
1323

1324
        return {
1✔
1325
            'is_humanitarian': is_humanitarian,
1326
            'is_humanitarian_by_attrib': is_humanitarian_by_attrib,
1327
            'contains_humanitarian_scope': 1 if (
1328
                is_humanitarian and self._version() in ['2.02', '2.03'] and all_true_and_not_empty(self.element.xpath('humanitarian-scope/@type')) and all_true_and_not_empty(self.element.xpath('humanitarian-scope/@code'))
1329
            ) else 0,
1330
            'contains_humanitarian_scope_without_humanitarian': 1 if (
1331
                (not is_humanitarian) and self._version() in ['2.02', '2.03'] and all_true_and_not_empty(self.element.xpath('humanitarian-scope/@type')) and all_true_and_not_empty(self.element.xpath('humanitarian-scope/@code'))
1332
            ) else 0,
1333
            'uses_humanitarian_clusters_vocab': 1 if (
1334
                is_humanitarian and self._version() in ['2.02', '2.03'] and self.element.xpath('sector/@vocabulary="10"')
1335
            ) else 0,
1336
            'uses_humanitarian_clusters_vocab_without_humanitarian': 1 if (
1337
                (not is_humanitarian) and self._version() in ['2.02', '2.03'] and self.element.xpath('sector/@vocabulary="10"')
1338
            ) else 0,
1339
            'uses_humanitarian_glide_codes': 1 if (
1340
                is_humanitarian and self._version() in ['2.02', '2.03'] and self.element.xpath('humanitarian-scope/@vocabulary') and self.element.xpath('humanitarian-scope/@vocabulary="1-2"')
1341
            ) else 0,
1342
            'uses_humanitarian_glide_codes_without_humanitarian': 1 if (
1343
                (not is_humanitarian) and self._version() in ['2.02', '2.03'] and self.element.xpath('humanitarian-scope/@vocabulary') and self.element.xpath('humanitarian-scope/@vocabulary="1-2"')
1344
            ) else 0,
1345
            'uses_humanitarian_hrp_codes': 1 if (
1346
                is_humanitarian and self._version() in ['2.02', '2.03'] and self.element.xpath('humanitarian-scope/@vocabulary') and self.element.xpath('humanitarian-scope/@vocabulary="2-1"')
1347
            ) else 0,
1348
            'uses_humanitarian_hrp_codes_without_humanitarian': 1 if (
1349
                (not is_humanitarian) and self._version() in ['2.02', '2.03'] and self.element.xpath('humanitarian-scope/@vocabulary') and self.element.xpath('humanitarian-scope/@vocabulary="2-1"')
1350
            ) else 0,
1351
        }
1352

1353
    def _transaction_type_code(self, transaction):
1✔
1354
        type_code = None
×
1355
        transaction_type = transaction.find('transaction-type')
×
1356
        if transaction_type is not None:
×
1357
            type_code = transaction_type.attrib.get('code')
×
1358
        return type_code
×
1359

1360
    @returns_numberdictdict
1✔
1361
    def transaction_dates(self):
1362
        """Generates a dictionary of dates for reported transactions, together
1363
           with the number of times they appear.
1364
        """
1365
        out = defaultdict(lambda: defaultdict(int))
×
1366
        for transaction in self.element.findall('transaction'):
×
1367
            date = transaction_date(transaction)
×
1368
            out[self._transaction_type_code(transaction)][str(date)] += 1
×
1369
        return out
×
1370

1371
    @returns_numberdictdict
1✔
1372
    def activity_dates(self):
1373
        out = defaultdict(lambda: defaultdict(int))
×
1374
        for activity_date in self.element.findall('activity-date'):
×
1375
            type_code = activity_date.attrib.get('type')
×
1376
            act_date = iso_date(activity_date)
×
1377
            out[type_code][str(act_date)] += 1
×
1378
        return out
×
1379

1380
    @returns_numberdictdict
1✔
1381
    def activity_dates_humanitarian(self):
1382
        out = defaultdict(lambda: defaultdict(int))
×
1383
        if ('humanitarian' in self.element.attrib) and (self.element.attrib['humanitarian'] in ['1', 'true']):
×
1384
            for activity_date in self.element.findall('activity-date'):
×
1385
                type_code = activity_date.attrib.get('type')
×
1386
                act_date = iso_date(activity_date)
×
1387
                out[type_code][str(act_date)] += 1
×
1388
        return out
×
1389

1390
    @returns_numberdictdict
1✔
1391
    def _count_transactions_by_type_by_year(self):
1392
        out = defaultdict(lambda: defaultdict(int))
×
1393
        for transaction in self.element.findall('transaction'):
×
1394
            out[self._transaction_type_code(transaction)][self._transaction_year(transaction)] += 1
×
1395
        return out
×
1396

1397
    @returns_numberdictdictdict
1✔
1398
    def _sum_transactions_by_type_by_year(self):
1399
        out = defaultdict(lambda: defaultdict(lambda: defaultdict(Decimal)))
×
1400
        for transaction in self.element.findall('transaction'):
×
1401
            value = transaction.find('value')
×
1402
            if (transaction.find('transaction-type') is not None and transaction.find('transaction-type').attrib.get('code') in [self._incoming_funds_code(), self._commitment_code(), self._disbursement_code(), self._expenditure_code()]):
×
1403

1404
                # Set transaction_value if a value exists for this transaction. Else set to 0
1405
                try:
×
1406
                    transaction_value = 0 if (value is None or value.text is None) else Decimal(value.text)
×
1407
                except InvalidOperation:
×
1408
                    transaction_value = 0
×
1409
                if self._transaction_year(transaction):
×
1410
                    out[self._transaction_type_code(transaction)][get_currency(self, transaction)][self._transaction_year(transaction)] += transaction_value
×
1411
        return out
×
1412

1413
    @returns_numberdictdictdict
1✔
1414
    @memoize
1✔
1415
    def sum_transactions_by_type_by_year_usd(self):
1416
        out = defaultdict(lambda: defaultdict(lambda: defaultdict(Decimal)))
×
1417

1418
        # Loop over the values in computed in _sum_transactions_by_type_by_year() and build a
1419
        # dictionary of USD values for the currency and year
1420
        for transaction_type, data in list(self._sum_transactions_by_type_by_year().items()):
×
1421
            for currency, years in list(data.items()):
×
1422
                for year, value in list(years.items()):
×
1423
                    # FIXME currently there's no currency data in this repo
1424
                    # after 2014, it is better to use 2014 than silently failing
1425
                    if year > 2014:
×
1426
                        year = 2014
×
1427
                    if None not in [currency, value, year]:
×
1428
                        out[transaction_type]['USD'][year] += get_USD_value(currency, value, year)
×
1429
        return out
×
1430

1431
    @returns_numberdictdict
1✔
1432
    def count_budgets_by_type_by_year(self):
1433
        out = defaultdict(lambda: defaultdict(int))
×
1434
        for budget in self.element.findall('budget'):
×
1435
            if budget_year(budget):
×
1436
                out[budget.attrib.get('type')][budget_year(budget)] += 1
×
1437
        return out
×
1438

1439
    @returns_numberdictdictdict
1✔
1440
    def sum_budgets_by_type_by_year(self):
1441
        out = defaultdict(lambda: defaultdict(lambda: defaultdict(Decimal)))
×
1442
        for budget in self.element.findall('budget'):
×
1443
            value = budget.find('value')
×
1444

1445
            # Set budget_value if a value exists for this budget. Else set to 0
1446
            try:
×
1447
                budget_value = Decimal(0) if (value is None or value.text is None) else Decimal(value.text)
×
1448
            except (TypeError, AttributeError, InvalidOperation):
×
1449
                budget_value = Decimal(0)
×
1450
            if budget_year(budget):
×
1451
                out[budget.attrib.get('type')][get_currency(self, budget)][budget_year(budget)] += budget_value
×
1452
        return out
×
1453

1454
    @returns_numberdictdictdict
1✔
1455
    def sum_budgets_by_type_by_year_usd(self):
1456
        out = defaultdict(lambda: defaultdict(lambda: defaultdict(Decimal)))
×
1457

1458
        # Loop over the values in computed in sum_budgets_by_type_by_year() and build a
1459
        # dictionary of USD values for the currency and year
1460
        for budget_type, data in self.sum_budgets_by_type_by_year().items():
×
1461
            for currency, years in data.items():
×
1462
                for year, value in years.items():
×
1463
                    if None not in [currency, value, year]:
×
1464
                        out[budget_type]['USD'][year] += get_USD_value(currency, value, year)
×
1465
        return out
×
1466

1467
    @returns_numberdict
1✔
1468
    def _count_planned_disbursements_by_year(self):
1469
        out = defaultdict(int)
×
1470
        for pd in self.element.findall('planned-disbursement'):
×
1471
            out[planned_disbursement_year(pd)] += 1
×
1472
        return out
×
1473

1474
    @returns_numberdictdict
1✔
1475
    def _sum_planned_disbursements_by_year(self):
1476
        out = defaultdict(lambda: defaultdict(Decimal))
×
1477
        for pd in self.element.findall('planned-disbursement'):
×
1478
            value = pd.find('value')
×
1479

1480
            # Set disbursement_value if a value exists for this disbursement. Else set to 0
1481
            disbursement_value = 0 if value is None else Decimal(value.text)
×
1482

1483
            out[get_currency(self, pd)][planned_disbursement_year(pd)] += disbursement_value
×
1484
        return out
×
1485

1486
    @returns_number
1✔
1487
    def activities_with_future_transactions(self):
1488
        for transaction in self.element.findall('transaction'):
×
1489
            if transaction_date(transaction) > self.today:
×
1490
                return 1
×
1491
        return 0
×
1492

1493
    @returns_numberdict
1✔
1494
    def provider_activity_id(self):
1495
        out = dict(Counter(self.element.xpath('transaction/provider-org/@provider-activity-id')))
×
1496
        if self.iati_identifier() in out:
×
1497
            del out[self.iati_identifier()]
×
1498
        return out
×
1499

1500
    def _sum_transactions(self, transaction_type):
1✔
1501
        return sum(self.sum_transactions_by_type_by_year_usd().get(transaction_type, {}).get('USD', {}).values())
×
1502

1503
    @returns_numberdict
1✔
1504
    def sum_commitments_and_disbursements_by_activity_id_usd(self):
1505
        sum_commitments_and_disbursements_usd = self._sum_transactions('C') + self._sum_transactions('2') + self._sum_transactions('D') + self._sum_transactions('3')
×
UNCOV
1506
        if sum_commitments_and_disbursements_usd:
×
UNCOV
1507
            return {self.iati_identifier(): sum_commitments_and_disbursements_usd}
×
1508
        else:
UNCOV
1509
            return {}
×
1510

1511
    def _reporting_org_ref(self):
1✔
1512
        """Reference for activity reporting organisation"""
1513
        for org in self.element.findall('reporting-org'):
1✔
1514
            ref = org.attrib.get('ref')
1✔
1515
            if ref:
1✔
1516
                return ref
1✔
UNCOV
1517
        return None
×
1518

1519
    def _check_org_reference(self, org, reporting_org, stat_type, out, prefixes=False):
1✔
1520
        """Calculate stat_type and add to out, if stat_type is total_valid_refs and prefixes
1521
           is True return increment counts in default dict of prefixes"""
1522
        ref = org.attrib.get('ref')
1✔
1523
        if ref is not None:
1✔
1524
            if stat_type == 'total_refs':
1✔
1525
                out += 1
1✔
1526
            else:
1527
                if ref:
1✔
1528
                    if stat_type == 'total_full_refs':
1✔
1529
                        out += 1
1✔
1530
                    else:
1531
                        if ref != reporting_org:
1✔
1532
                            if stat_type == 'total_notself_refs':
1✔
1533
                                out += 1
1✔
1534
                            elif stat_type == 'total_valid_refs':
1✔
1535
                                valid, prefix = valid_org_prefix(self._major_version(), ref)
1✔
1536
                                if prefixes:
1✔
1537
                                    out[prefix] += 1
1✔
1538
                                else:
1539
                                    if valid:
1✔
1540
                                        out += 1
1✔
1541
        return out
1✔
1542

1543
    def _participating_org_stats(self, org_type_id, stat_type, prefixes=False):
1✔
1544
        """Calculate stat_type for participating organisation role id for activity"""
1545
        reporting_org = self._reporting_org_ref()
1✔
1546
        out = defaultdict(int) if prefixes else 0
1✔
1547
        for org in self.element.findall('participating-org'):
1✔
1548
            role = org.attrib.get('role')
1✔
1549
            if role and role == org_type_id:
1✔
1550
                if stat_type == 'total':
1✔
1551
                    out += 1
1✔
1552
                else:
1553
                    if prefixes:
1✔
1554
                        self._check_org_reference(org, reporting_org, stat_type, out, prefixes)
1✔
1555
                    else:
1556
                        out = self._check_org_reference(org, reporting_org, stat_type, out, prefixes)
1✔
1557
        return out
1✔
1558

1559
    def _transaction_org_stats(self, org_type, stat_type, prefixes=False):
1✔
1560
        """Calculate stat_type for transaction organisation type"""
1561
        reporting_org = self._reporting_org_ref()
1✔
1562
        out = defaultdict(int) if prefixes else 0
1✔
1563
        for transaction in self.element.findall('transaction'):
1✔
1564
            org = transaction.find(org_type)
1✔
1565
            if org is not None:
1✔
1566
                if stat_type == 'total':
1✔
1567
                    out += 1
1✔
1568
                else:
1569
                    if prefixes:
1✔
1570
                        self._check_org_reference(org, reporting_org, stat_type, out, prefixes)
1✔
1571
                    else:
1572
                        out = self._check_org_reference(org, reporting_org, stat_type, out, prefixes)
1✔
1573
        return out
1✔
1574

1575
    def _participating_org_all_stats(self, org_type_id):
1✔
1576
        """Calculate all statistics for activity participating organisation type"""
1577
        return {'total_orgs': self._participating_org_stats(org_type_id, 'total'),
1✔
1578
                'total_refs': self._participating_org_stats(org_type_id, 'total_refs'),
1579
                'total_full_refs': self._participating_org_stats(org_type_id, 'total_full_refs'),
1580
                'total_notself_refs': self._participating_org_stats(org_type_id, 'total_notself_refs'),
1581
                'total_valid_refs': self._participating_org_stats(org_type_id, 'total_valid_refs')}
1582

1583
    def _transaction_org_all_stats(self, org_type):
1✔
1584
        """Calculate all statistics for transaction organisation type"""
1585
        return {'total_orgs': self._transaction_org_stats(org_type, 'total'),
1✔
1586
                'total_refs': self._transaction_org_stats(org_type, 'total_refs'),
1587
                'total_full_refs': self._transaction_org_stats(org_type, 'total_full_refs'),
1588
                'total_notself_refs': self._transaction_org_stats(org_type, 'total_notself_refs'),
1589
                'total_valid_refs': self._transaction_org_stats(org_type, 'total_valid_refs')}
1590

1591
    @returns_numberdict
1✔
1592
    def funding_org_transaction_stats(self):
1593
        """Calculate all statistics for activity funding organisation"""
1594
        return self._participating_org_all_stats('1')
1✔
1595

1596
    @returns_numberdict
1✔
1597
    def funding_org_valid_prefixes(self):
1598
        """Calculate activity funding organisation valid prefix counts"""
1599
        return self._participating_org_stats('1', 'total_valid_refs', prefixes=True)
1✔
1600

1601
    @returns_numberdict
1✔
1602
    def accountable_org_transaction_stats(self):
1603
        """Calculate all statistics for activity accountable organisation"""
1604
        return self._participating_org_all_stats('2')
1✔
1605

1606
    @returns_numberdict
1✔
1607
    def accountable_org_valid_prefixes(self):
1608
        """Calculate activity accountable organisation valid prefix counts"""
1609
        return self._participating_org_stats('2', 'total_valid_refs', prefixes=True)
1✔
1610

1611
    @returns_numberdict
1✔
1612
    def extending_org_transaction_stats(self):
1613
        """Calculate all statistics for activity extending organisation"""
1614
        return self._participating_org_all_stats('3')
1✔
1615

1616
    @returns_numberdict
1✔
1617
    def extending_org_valid_prefixes(self):
1618
        """Calculate activity extending organisation valid prefix counts"""
1619
        return self._participating_org_stats('3', 'total_valid_refs', prefixes=True)
1✔
1620

1621
    @returns_numberdict
1✔
1622
    def implementing_org_transaction_stats(self):
1623
        """Calculate all statistics for activity implementing organisation"""
1624
        return self._participating_org_all_stats('4')
1✔
1625

1626
    @returns_numberdict
1✔
1627
    def implementing_org_valid_prefixes(self):
1628
        """Calculate activity implementing organisation valid prefix counts"""
1629
        return self._participating_org_stats('4', 'total_valid_refs', prefixes=True)
1✔
1630

1631
    @returns_numberdict
1✔
1632
    def provider_org_transaction_stats(self):
1633
        """Calculate all statistics for activity transactions provider organisation"""
1634
        return self._transaction_org_all_stats('provider-org')
1✔
1635

1636
    @returns_numberdict
1✔
1637
    def provider_org_valid_prefixes(self):
1638
        """Calculate activity transaction provider organisation valid prefix counts"""
1639
        return self._transaction_org_stats('provider-org', 'total_valid_refs', prefixes=True)
1✔
1640

1641
    @returns_numberdict
1✔
1642
    def receiver_org_transaction_stats(self):
1643
        """Calculate all statistics for activity transactions receiver organisation"""
1644
        return self._transaction_org_all_stats('receiver-org')
1✔
1645

1646
    @returns_numberdict
1✔
1647
    def receiver_org_valid_prefixes(self):
1648
        """Calculate activity transaction receiver organisation valid prefix counts"""
1649
        return self._transaction_org_stats('receiver-org', 'total_valid_refs', prefixes=True)
1✔
1650

1651
    @returns_number
1✔
1652
    def transaction_total(self):
1653
        """Calculate activity transaction counts"""
1654
        out = 0
1✔
1655
        for transaction in self.element.findall('transaction'):
1✔
1656
            out += 1
1✔
1657
        return out
1✔
1658

1659

1660
ckan = json.load(open('helpers/ckan.json'))
1✔
1661
publisher_re = re.compile(r'(.*)\-[^\-]')
1✔
1662

1663

1664
class GenericFileStats(object):
1✔
1665
    blank = False
1✔
1666

1667
    @returns_numberdict
1✔
1668
    def versions(self):
1669
        return {self.root.attrib.get('version'): 1}
×
1670

1671
    @returns_numberdict
1✔
1672
    def version_mismatch(self):
UNCOV
1673
        file_version = self.root.attrib.get('version')
×
UNCOV
1674
        element_versions = self.root.xpath('//iati-activity/@version')
×
UNCOV
1675
        element_versions = list(set(element_versions))
×
UNCOV
1676
        return {
×
1677
            'true' if (file_version is not None and len(element_versions) and [file_version] != element_versions) else 'false': 1
1678
        }
1679

1680
    @returns_numberdict
1✔
1681
    def validation(self):
1682
        version = self.root.attrib.get('version')
×
1683
        if version in [None, '1', '1.0', '1.00']:
×
1684
            version = '1.01'
×
1685
        try:
×
1686
            with open('helpers/schemas/{0}/{1}'.format(version, self.schema_name)) as f:
×
UNCOV
1687
                xmlschema_doc = etree.parse(f)
×
1688
                xmlschema = etree.XMLSchema(xmlschema_doc)
×
1689
                if xmlschema.validate(self.doc):
×
1690
                    return {'pass': 1}
×
1691
                else:
UNCOV
1692
                    return {'fail': 1}
×
UNCOV
1693
        except IOError:
×
UNCOV
1694
            debug(self, 'Unsupported version \'{0}\' '.format(version))
×
1695
            return {'fail': 1}
×
1696

1697
    @returns_numberdict
1✔
1698
    def wrong_roots(self):
1699
        tag = self.root.tag
×
1700
        try:
×
1701
            ckan_type = ckan[publisher_re.match(self.fname).group(1)][self.fname]['extras']['filetype']
×
UNCOV
1702
            if not ((tag == 'iati-organisations' and ckan_type == '"organisation"') or (tag == 'iati-activities' and ckan_type == '"activity"')):
×
UNCOV
1703
                return {tag: 1}
×
UNCOV
1704
        except KeyError:
×
1705
            pass
×
1706

1707
    @returns_number
1✔
1708
    def file_size(self):
1709
        return os.stat(self.inputfile).st_size
×
1710

1711
    @returns_numberdict
1✔
1712
    def file_size_bins(self):
1713
        file_size = os.stat(self.inputfile).st_size
×
1714
        if file_size < 1 * 1024 * 1024:
×
1715
            return {'<1MB': 1}
×
1716
        elif file_size < 5 * 1024 * 1024:
×
1717
            return {'1-5MB': 1}
×
UNCOV
1718
        elif file_size < 10 * 1024 * 1024:
×
1719
            return {'5-10MB': 1}
×
UNCOV
1720
        elif file_size < 20 * 1024 * 1024:
×
UNCOV
1721
            return {'10-20MB': 1}
×
1722
        else:
UNCOV
1723
            return {'>20MB': 1}
×
1724

1725
    """
1726
    @returns_date
1727
    @memoize
1728
    def updated(self):
1729
        if self.inputfile.startswith('data/'):
1730
            cwd = os.getcwd()
1731
            os.chdir('data')
1732
            out = subprocess.check_output(['git', 'log', '-1', '--format="%ai"', '--', self.inputfile[5:]]).strip('"\n')
1733
            os.chdir(cwd)
1734
            return out
1735

1736
    @returns_numberdict
1737
    def updated_dates(self):
1738
        return {self.updated().split(' ')[0]:1}
1739
    """
1740

1741
    @returns_number
1✔
1742
    def empty(self):
UNCOV
1743
        return 0
×
1744

1745
    @returns_number
1✔
1746
    def invalidxml(self):
1747
        # Must be valid XML to have loaded this function
UNCOV
1748
        return 0
×
1749

1750
    def nonstandardroots(self):
1✔
UNCOV
1751
        return 0
×
1752

1753
    def toolarge(self):
1✔
UNCOV
1754
        return 0
×
1755

1756

1757
class ActivityFileStats(GenericFileStats):
1✔
1758
    """ Stats calculated for an IATI Activity XML file. """
1759
    doc = None
1✔
1760
    root = None
1✔
1761
    schema_name = 'iati-activities-schema.xsd'
1✔
1762

1763
    @returns_number
1✔
1764
    def activity_files(self):
UNCOV
1765
        return 1
×
1766

1767
    @returns_numberdictdict
1✔
1768
    @memoize
1✔
1769
    def codelist_values(self):
1770
        out = defaultdict(lambda: defaultdict(int))
×
1771
        for path in codelist_mappings[self._major_version()]:
×
UNCOV
1772
            values = self.root.xpath(path)
×
UNCOV
1773
            for value in values:
×
UNCOV
1774
                out[path][value] += 1
×
1775
        return out
×
1776

1777
    @returns_numberdictdict
1✔
1778
    def codelist_values_by_major_version(self):
UNCOV
1779
        out = self.codelist_values()
×
UNCOV
1780
        return {self._major_version(): out}
×
1781

1782
    @returns_numberdict
1✔
1783
    @memoize
1✔
1784
    def _major_version(self):
UNCOV
1785
        if self._version().startswith('2.'):
×
UNCOV
1786
            return '2'
×
1787
        else:
UNCOV
1788
            return '1'
×
1789

1790
    @returns_numberdict
1✔
1791
    @memoize
1✔
1792
    def _version(self):
UNCOV
1793
        allowed_versions = CODELISTS['2']['Version']
×
1794
        version = self.root.get('version')
×
UNCOV
1795
        if version and version in allowed_versions:
×
UNCOV
1796
            return version
×
1797
        else:
UNCOV
1798
            return '1.01'
×
1799

1800

1801
class PublisherStats(object):
1✔
1802
    """ Stats calculated for an IATI Publisher (directory in the data directory). """
1803
    aggregated = None
1✔
1804
    blank = False
1✔
1805
    strict = False  # (Setting this to true will ignore values that don't follow the schema)
1✔
1806
    context = ''
1✔
1807

1808
    @returns_dict
1✔
1809
    def bottom_hierarchy(self):
1810
        def int_or_None(x):
1✔
1811
            try:
1✔
1812
                return int(x)
1✔
1813
            except ValueError:
1✔
1814
                return None
1✔
1815

1816
        hierarchies = self.aggregated['by_hierarchy'].keys()
1✔
1817
        hierarchies_ints = [x for x in map(int_or_None, hierarchies) if x is not None]
1✔
1818
        if not hierarchies_ints:
1✔
1819
            return {}
1✔
1820
        bottom_hierarchy_key = str(max(hierarchies_ints))
1✔
1821
        try:
1✔
1822
            return copy.deepcopy(self.aggregated['by_hierarchy'][bottom_hierarchy_key])
1✔
UNCOV
1823
        except KeyError:
×
1824
            return {}
×
1825

1826
    @returns_numberdict
1✔
1827
    def publishers_per_version(self):
UNCOV
1828
        versions = self.aggregated['versions'].keys()
×
1829
        return dict((v, 1) for v in versions)
×
1830

1831
    @returns_number
1✔
1832
    def publishers(self):
1833
        return 1
×
1834

1835
    @returns_numberdict
1✔
1836
    def publishers_validation(self):
UNCOV
1837
        if 'fail' in self.aggregated['validation']:
×
UNCOV
1838
            return {'fail': 1}
×
1839
        else:
1840
            return {'pass': 1}
×
1841

1842
    @returns_numberdict
1✔
1843
    def publisher_has_org_file(self):
UNCOV
1844
        if 'organisation_files' in self.aggregated and self.aggregated['organisation_files'] > 0:
×
UNCOV
1845
            return {'yes': 1}
×
1846
        else:
UNCOV
1847
            return {'no': 1}
×
1848

1849
    # The following two functions have different names to the AllData equivalents
1850
    # This is because the aggregation of the publisher level functions will ignore duplication between publishers
1851

1852
    @returns_number
1✔
1853
    @memoize
1✔
1854
    def publisher_unique_identifiers(self):
UNCOV
1855
        return len(self.aggregated['iati_identifiers'])
×
1856

1857
    @returns_dict
1✔
1858
    def _reference_spend_data(self):
1859
        """Lookup the reference spend data (value and currency) for this publisher (obtained by using the
1860
           name of the folder), for years 2014 and 2015.
1861
           Outputs an empty string for each element where there is no data.
1862
        """
UNCOV
1863
        if self.folder in reference_spend_data.keys():
×
1864

1865
            # Note that the values may be strings or human-readable numbers (i.e. with commas to seperate thousands)
UNCOV
1866
            return {'2014': {'ref_spend': reference_spend_data[self.folder]['2014_ref_spend'].replace(',', '') if is_number(reference_spend_data[self.folder]['2014_ref_spend'].replace(',', '')) else '',
×
1867
                    'currency': reference_spend_data[self.folder]['currency'], 'official_forecast_usd': ''},
1868
                    '2015': {'ref_spend': reference_spend_data[self.folder]['2015_ref_spend'].replace(',', '') if is_number(reference_spend_data[self.folder]['2015_ref_spend'].replace(',', '')) else '',
1869
                             'currency': reference_spend_data[self.folder]['currency'],
1870
                             'official_forecast_usd': reference_spend_data[self.folder]['2015_official_forecast'].replace(',', '') if is_number(reference_spend_data[self.folder]['2015_official_forecast'].replace(',', '')) else ''},
1871
                    'spend_data_error_reported': 1 if reference_spend_data[self.folder]['spend_data_error_reported'] else 0,
1872
                    'DAC': 1 if reference_spend_data[self.folder]['DAC'] else 0}
1873
        else:
UNCOV
1874
            return {}
×
1875

1876
    @returns_dict
1✔
1877
    def reference_spend_data_usd(self):
1878
        """For each year that there is reference spend data for this publisher, convert this
1879
           to the USD value for the given year
1880
           Outputs an empty string for each element where there is no data.
1881
        """
1882

1883
        output = {}
×
UNCOV
1884
        ref_spend_data = self._reference_spend_data()
×
1885

1886
        # Construct a list of reference spend data related to years 2015 & 2014 only
UNCOV
1887
        ref_data_years = [x for x in ref_spend_data.items() if is_number(x[0])]
×
1888

1889
        # Loop over the years
1890
        for year, data in ref_data_years:
×
1891
            # Construct output dictionary with USD values
UNCOV
1892
            output[year] = {}
×
1893
            output[year]['ref_spend'] = str(get_USD_value(data['currency'], data['ref_spend'], year)) if is_number(data['ref_spend']) else ''
×
1894
            output[year]['official_forecast'] = data['official_forecast_usd'] if is_number(data['official_forecast_usd']) else ''
×
1895

1896
        # Append the spend error and DAC booleans and return
UNCOV
1897
        output['spend_data_error_reported'] = ref_spend_data.get('spend_data_error_reported', 0)
×
UNCOV
1898
        output['DAC'] = ref_spend_data.get('DAC', 0)
×
1899
        return output
×
1900

1901
    @returns_numberdict
1✔
1902
    def publisher_duplicate_identifiers(self):
1903
        return {k: v for k, v in self.aggregated['iati_identifiers'].items() if v > 1}
×
1904

1905
    def _timeliness_transactions(self):
1✔
1906
        tt = self.aggregated['transaction_timing']
×
1907
        if [tt['30'], tt['60'], tt['90']].count(0) <= 1:
×
1908
            return 'Monthly'
×
1909
        elif [tt['30'], tt['60'], tt['90']].count(0) <= 2:
×
1910
            return 'Quarterly'
×
UNCOV
1911
        elif tt['180'] != 0:
×
1912
            return 'Six-monthly'
×
UNCOV
1913
        elif tt['360'] != 0:
×
UNCOV
1914
            return 'Annual'
×
1915
        else:
1916
            return 'Beyond one year'
×
1917

1918
    @no_aggregation
1✔
1919
    def timelag(self):
1920
        def previous_months_generator(d):
×
1921
            year = d.year
×
1922
            month = d.month
×
1923
            for i in range(0, 12):
×
1924
                month -= 1
×
1925
                if month <= 0:
×
1926
                    year -= 1
×
1927
                    month = 12
×
1928
                yield '{}-{}'.format(year, str(month).zfill(2))
×
1929
        previous_months = list(previous_months_generator(self.today))
×
1930
        transaction_months_with_year = self.aggregated['transaction_months_with_year']
×
1931
        if [x in transaction_months_with_year for x in previous_months[:3]].count(True) >= 2:
×
1932
            return 'One month'
×
1933
        elif [x in transaction_months_with_year for x in previous_months[:3]].count(True) >= 1:
×
1934
            return 'A quarter'
×
UNCOV
1935
        elif True in [x in transaction_months_with_year for x in previous_months[:6]]:
×
1936
            return 'Six months'
×
UNCOV
1937
        elif True in [x in transaction_months_with_year for x in previous_months[:12]]:
×
UNCOV
1938
            return 'One year'
×
1939
        else:
1940
            return 'More than one year'
×
1941

1942
    def _transaction_alignment(self):
1✔
1943
        transaction_months = self.aggregated['transaction_months'].keys()
×
1944
        if len(transaction_months) == 12:
×
1945
            return 'Monthly'
×
UNCOV
1946
        elif len(set(map(lambda x: (int(x) - 1) // 3, transaction_months))) == 4:
×
1947
            return 'Quarterly'
×
UNCOV
1948
        elif len(transaction_months) >= 1:
×
UNCOV
1949
            return 'Annually'
×
1950
        else:
UNCOV
1951
            return ''
×
1952

1953
    @no_aggregation
1✔
1954
    @memoize
1✔
1955
    def _budget_length_median(self):
1956
        budget_lengths = self.aggregated['budget_lengths']
×
1957
        budgets = sum(budget_lengths.values())
×
1958
        i = 0
×
1959
        median = None
×
UNCOV
1960
        for k, v in sorted([(int(k), v) for k, v in budget_lengths.items()]):
×
1961
            i += v
×
UNCOV
1962
            if i >= (budgets / 2.0):
×
1963
                if median:
×
1964
                    # Handle the case where the median falls between two frequency bins
1965
                    median = (median + k) / 2.0
×
1966
                else:
UNCOV
1967
                    median = k
×
UNCOV
1968
                if i != (budgets / 2.0):
×
1969
                    break
×
1970
        return median
×
1971

1972
    def _budget_alignment(self):
1✔
1973
        median = self._budget_length_median()
×
1974
        if median is None:
×
1975
            return 'Not known'
×
UNCOV
1976
        elif median < 100:
×
1977
            return 'Quarterly'
×
UNCOV
1978
        elif median < 370:
×
UNCOV
1979
            return 'Annually'
×
1980
        else:
1981
            return 'Beyond one year'
×
1982

1983
    @no_aggregation
1✔
1984
    def date_extremes(self):
1985
        activity_dates = {
×
1986
            k: list(filter(lambda x: x is not None, map(iso_date_match, v.keys()))) for k, v in self.aggregated['activity_dates'].items()
1987
        }
1988
        min_dates = {k: min(v) for k, v in activity_dates.items() if v}
×
UNCOV
1989
        max_dates = {k: max(v) for k, v in activity_dates.items() if v}
×
UNCOV
1990
        overall_min = str(min(min_dates.values())) if min_dates else None
×
UNCOV
1991
        overall_max = str(max(max_dates.values())) if min_dates else None
×
UNCOV
1992
        return {
×
1993
            'min': {
1994
                'overall': overall_min,
1995
                'by_type': {k: str(v) for k, v in min_dates.items()}
1996
            },
1997
            'max': {
1998
                'overall': overall_max,
1999
                'by_type': {k: str(v) for k, v in max_dates.items()}
2000
            },
2001
        }
2002

2003
    @no_aggregation
1✔
2004
    def most_recent_transaction_date(self):
2005
        """Computes the latest non-future transaction data across a dataset
2006
        """
2007
        nonfuture_transaction_dates = list(filter(
×
2008
            lambda x: x is not None and x <= self.today,
2009
            map(iso_date_match, sum((list(x.keys()) for x in self.aggregated['transaction_dates'].values()), []))))
UNCOV
2010
        if nonfuture_transaction_dates:
×
UNCOV
2011
            return str(max(nonfuture_transaction_dates))
×
2012

2013
    @no_aggregation
1✔
2014
    def _latest_transaction_date(self):
2015
        """Computes the latest transaction data across a dataset. Can be in the future
2016
        """
2017
        transaction_dates = list(filter(
×
2018
            lambda x: x is not None,
2019
            map(iso_date_match, sum((list(x.keys()) for x in self.aggregated['transaction_dates'].values()), []))))
UNCOV
2020
        if transaction_dates:
×
2021
            return str(max(transaction_dates))
×
2022

2023
    @returns_numberdict
1✔
2024
    def provider_activity_id_without_own(self):
UNCOV
2025
        out = {k: v for k, v in self.aggregated['provider_activity_id'].items() if k not in self.aggregated['iati_identifiers']}
×
UNCOV
2026
        return out
×
2027

2028
    @returns_numberdictdict
1✔
2029
    def sum_commitments_and_disbursements_by_activity_id_by_publisher_id_usd(self):
2030
        # These 2 by_publisher_id functions produce similar data to the invert
2031
        # step, but we have to include them here to make that data available in
2032
        # the AllDataStats step.
UNCOV
2033
        return {self.folder: self.aggregated['sum_commitments_and_disbursements_by_activity_id_usd']}
×
2034

2035
    @returns_numberdictdict
1✔
2036
    def iati_identifiers_by_publisher_id(self):
2037
        # See comment on by_publisher_id above
UNCOV
2038
        return {self.folder: self.aggregated['iati_identifiers']}
×
2039

2040

2041
class OrganisationFileStats(GenericFileStats):
1✔
2042
    """ Stats calculated for an IATI Organisation XML file. """
2043
    doc = None
1✔
2044
    root = None
1✔
2045
    schema_name = 'iati-organisations-schema.xsd'
1✔
2046

2047
    @returns_number
1✔
2048
    def organisation_files(self):
UNCOV
2049
        return 1
×
2050

2051

2052
class OrganisationStats(CommonSharedElements):
1✔
2053
    """ Stats calculated on a single iati-organisation. """
2054
    blank = False
1✔
2055

2056
    @returns_number
1✔
2057
    def organisations(self):
2058
        return 1
×
2059

2060
    @returns_numberdict
1✔
2061
    def elements(self):
2062
        return element_to_count_dict(self.element, 'iati-organisation', {})
×
2063

2064
    @returns_numberdict
1✔
2065
    def elements_total(self):
UNCOV
2066
        return element_to_count_dict(self.element, 'iati-organisation', defaultdict(int), True)
×
2067

2068

2069
class AllDataStats(object):
1✔
2070
    blank = False
1✔
2071

2072
    @returns_number
1✔
2073
    def unique_identifiers(self):
2074
        return len(self.aggregated['iati_identifiers'])
×
2075

2076
    @returns_numberdict
1✔
2077
    def _duplicate_identifiers(self):
2078
        return {k: v for k, v in self.aggregated['iati_identifiers'].items() if v > 1}
×
2079

2080
    @returns_numberdict
1✔
2081
    def traceable_sum_commitments_and_disbursements_by_publisher_id(self):
2082
        out = defaultdict(Decimal)
×
2083
        for publisher_id, d in self.aggregated['sum_commitments_and_disbursements_by_activity_id_by_publisher_id_usd'].items():
×
UNCOV
2084
            for k, v in d.items():
×
UNCOV
2085
                if k in self.aggregated['provider_activity_id_without_own']:
×
UNCOV
2086
                    out[publisher_id] += v
×
2087
        return out
×
2088

2089
    @returns_numberdict
1✔
2090
    def traceable_sum_commitments_and_disbursements_by_publisher_id_denominator(self):
2091
        out = defaultdict(Decimal)
×
UNCOV
2092
        for publisher_id, d in self.aggregated['sum_commitments_and_disbursements_by_activity_id_by_publisher_id_usd'].items():
×
UNCOV
2093
            for k, v in d.items():
×
UNCOV
2094
                out[publisher_id] += v
×
2095
        return out
×
2096

2097
    @returns_numberdict
1✔
2098
    def traceable_activities_by_publisher_id(self):
2099
        out = defaultdict(int)
×
2100
        for publisher_id, iati_identifiers_counts in self.aggregated['iati_identifiers_by_publisher_id'].items():
×
UNCOV
2101
            for iati_identifier, count in iati_identifiers_counts.items():
×
UNCOV
2102
                if iati_identifier in self.aggregated['provider_activity_id_without_own']:
×
UNCOV
2103
                    out[publisher_id] += count
×
2104
        return out
×
2105

2106
    @returns_numberdict
1✔
2107
    def traceable_activities_by_publisher_id_denominator(self):
2108
        out = defaultdict(int)
×
UNCOV
2109
        for publisher_id, iati_identifiers_counts in self.aggregated['iati_identifiers_by_publisher_id'].items():
×
UNCOV
2110
            for iati_identifier, count in iati_identifiers_counts.items():
×
UNCOV
2111
                out[publisher_id] += count
×
UNCOV
2112
        return out
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc