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

akvo / akvo-rsr / fc8c918a-a06e-489c-b4ce-5b0a8db2084b

29 Nov 2023 08:47PM UTC coverage: 69.411% (-0.08%) from 69.495%
fc8c918a-a06e-489c-b4ce-5b0a8db2084b

push

semaphore

zuhdil
Fix memory issue on org results indicators excel report

66 of 124 new or added lines in 2 files covered. (53.23%)

1 existing line in 1 file now uncovered.

17588 of 25339 relevant lines covered (69.41%)

0.69 hits per line

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

42.41
/akvo/rsr/project_overview.py
1
# -*- coding: utf-8 -*-
2

3
"""Akvo RSR is covered by the GNU Affero General Public License.
1✔
4

5
See more details in the license.txt file located at the root folder of the
6
Akvo RSR module. For additional details on the GNU license please
7
see < http://www.gnu.org/licenses/agpl.html >.
8
"""
9

10
import copy
1✔
11
from collections import OrderedDict
1✔
12
from datetime import date
1✔
13
from functools import cached_property
1✔
14
from django.conf import settings
1✔
15
from akvo.rsr.models import IndicatorPeriod, IndicatorPeriodData
1✔
16
from akvo.rsr.models.result.utils import QUALITATIVE, PERCENTAGE_MEASURE, calculate_percentage
1✔
17
from akvo.utils import ensure_decimal, ObjectReaderProxy
1✔
18
from enum import Enum
1✔
19

20

21
def is_aggregating_targets(project):
1✔
22
    return project.id in settings.AGGREGATE_TARGETS
1✔
23

24

25
def merge_unique(l1, l2):
1✔
26
    out = list(l1)
×
27
    for i in l2:
×
28
        if i not in out:
×
29
            out.append(i)
×
30
    return out
×
31

32

33
def get_periods_with_contributors(root_periods, aggregate_targets=False):
1✔
34
    periods = get_periods_hierarchy_flatlist(root_periods)
×
35
    periods_tree = make_object_tree_from_flatlist(periods, 'parent_period')
×
36
    project = periods.first().indicator.result.project
×
37
    disaggregations = get_disaggregations(project)
×
38
    return [PeriodProxy(n['item'], n['children'], aggregate_targets, disaggregations) for n in periods_tree]
×
39

40

41
def get_disaggregations(project):
1✔
42
    disaggregations = OrderedDict()
1✔
43
    for n in project.dimension_names.all():
1✔
44
        disaggregations[n.name] = OrderedDict()
×
45
        for v in n.dimension_values.all():
×
46
            disaggregations[n.name][v.value] = None
×
47
    return disaggregations
1✔
48

49

50
def get_periods_hierarchy_flatlist(root_periods):
1✔
51
    family = {period.id for period in root_periods}
1✔
52
    while True:
53
        children = set(
1✔
54
            IndicatorPeriod.objects.filter(
55
                parent_period__in=family
56
            ).values_list(
57
                'id', flat=True
58
            ))
59
        if family.union(children) == family:
1✔
60
            break
1✔
61

62
        family = family.union(children)
1✔
63

64
    return IndicatorPeriod.objects.select_related(
1✔
65
        'indicator__result__project',
66
        'indicator__result__project__primary_location__country',
67
        'parent_period',
68
        'label',
69
    ).prefetch_related(
70
        'data',
71
        'data__user',
72
        'data__approved_by',
73
        'data__comments',
74
        'data__comments__user',
75
        'data__disaggregations',
76
        'data__disaggregations__dimension_value',
77
        'data__disaggregations__dimension_value__name',
78
        'disaggregation_targets',
79
        'disaggregation_targets__dimension_value',
80
        'disaggregation_targets__dimension_value__name'
81
    ).filter(pk__in=family)
82

83

84
def make_object_tree_from_flatlist(flatlist, parent_attr):
1✔
85
    tree = []
1✔
86
    lookup = {}
1✔
87
    ids = [o.id for o in flatlist]
1✔
88

89
    for obj in flatlist:
1✔
90
        item_id = obj.id
1✔
91
        if item_id not in lookup:
1✔
92
            lookup[item_id] = {'children': []}
1✔
93
        lookup[item_id]['item'] = obj
1✔
94
        node = lookup[item_id]
1✔
95

96
        parent_obj = getattr(obj, parent_attr)
1✔
97
        parent_id = parent_obj.id if parent_obj else None
1✔
98
        if not parent_id or parent_id not in ids:
1✔
99
            tree.append(node)
1✔
100
        else:
101
            if parent_id not in lookup:
1✔
UNCOV
102
                lookup[parent_id] = {'children': []}
×
103
            lookup[parent_id]['children'].append(node)
1✔
104

105
    return tree
1✔
106

107

108
class IndicatorType(Enum):
1✔
109
    UNIT = 1
1✔
110
    PERCENTAGE = 2
1✔
111
    NARRATIVE = 3
1✔
112

113
    @classmethod
1✔
114
    def get_type(cls, indicator):
1✔
115
        if indicator.type == QUALITATIVE:
1✔
116
            return cls.NARRATIVE
×
117
        if indicator.measure == PERCENTAGE_MEASURE:
1✔
118
            return cls.PERCENTAGE
1✔
119
        return cls.UNIT
1✔
120

121

122
class PeriodProxy(ObjectReaderProxy):
1✔
123
    def __init__(self, period, children=[], aggregate_targets=False, project_disaggregations=None):
1✔
124
        super().__init__(period)
×
125
        self.type = IndicatorType.get_type(period.indicator)
×
126
        self.aggregate_targets = aggregate_targets
×
127
        self._project_disaggregations = project_disaggregations
×
128
        self._children = children
×
129
        self._project = None
×
130
        self._updates = None
×
131
        self._actual_comment = None
×
132
        self._actual_numerator = None
×
133
        self._actual_denominator = None
×
134
        self._target_value = None
×
135
        self._contributors = None
×
136
        self._countries = None
×
137
        self._locations = None
×
138
        self._disaggregation_targets = None
×
139
        self._disaggregation_contributions = None
×
140
        self._disaggregation_contributions_view = None
×
141
        self._indicator_target_value = None
×
142
        self._use_indicator_target = None
×
143

144
    @property
1✔
145
    def project(self):
1✔
146
        if self._project is None:
×
147
            self._project = self._real.indicator.result.project
×
148
        return self._project
×
149

150
    @property
1✔
151
    def updates(self):
1✔
152
        if self._updates is None:
×
153
            self._updates = UpdateCollection(self._real, self.type)
×
154
        return self._updates
×
155

156
    @property
1✔
157
    def contributors(self):
1✔
158
        if self._contributors is None:
×
159
            children = self._children if self.project.aggregate_children else []
×
160
            self._contributors = ContributorCollection(children, self.type, self._project_disaggregations)
×
161
        return self._contributors
×
162

163
    @property
1✔
164
    def target_value(self):
1✔
165
        if self._target_value is None:
×
166
            if self.type == IndicatorType.NARRATIVE:
×
167
                self._target_value = self._real.target_value
×
168
            elif self.aggregate_targets and self.type != IndicatorType.PERCENTAGE:
×
169
                self._target_value = _aggregate_period_targets(self._real, self._children)
×
170
            else:
171
                self._target_value = ensure_decimal(self._real.target_value)
×
172
        return self._target_value
×
173

174
    @property
1✔
175
    def use_indicator_target(self):
1✔
176
        if self._use_indicator_target is None:
×
177
            program = self.project.get_program()
×
178
            targets_at = program.targets_at if program else self.targets_at
×
179
            self._use_indicator_target = True if targets_at == 'indicator' else False
×
180
        return self._use_indicator_target
×
181

182
    @property
1✔
183
    def indicator_target_value(self):
1✔
184
        if self._indicator_target_value is None:
×
185
            if not self.use_indicator_target:
×
186
                self._indicator_target_value = 0
×
187
            elif self.type == IndicatorType.NARRATIVE:
×
188
                narrative = self.indicator.target_value
×
189
                self._indicator_target_value = narrative if narrative else ''
×
190
            elif self.use_indicator_target and self.type != IndicatorType.PERCENTAGE:
×
191
                self._indicator_target_value = _aggregate_indicator_targets(self._real, self._children)
×
192
            else:
193
                self._indicator_target_value = ensure_decimal(self.indicator.target_value)
×
194
        return self._indicator_target_value
×
195

196
    @cached_property
1✔
197
    def is_cumulative(self):
1✔
198
        return self.indicator.is_cumulative()
×
199

200
    @property
1✔
201
    def actual_comment(self):
1✔
202
        if self._actual_comment is None:
×
203
            self._actual_comment = self._real.actual_comment.split(' | ') \
×
204
                if self._real.actual_comment \
205
                else False
206
        return self._actual_comment or None
×
207

208
    @cached_property
1✔
209
    def actual_value(self):
1✔
210
        if self.type == IndicatorType.PERCENTAGE:
×
211
            return calculate_percentage(self.actual_numerator, self._actual_denominator)
×
212
        if self.is_cumulative and self.period_start and self.period_start > date.today():
×
213
            return 0
×
214
        return self._real.actual_value
×
215

216
    @property
1✔
217
    def actual_numerator(self):
1✔
218
        if self._actual_numerator is None and self.type == IndicatorType.PERCENTAGE:
×
219
            self._actual_numerator = self.updates.total_numerator + self.contributors.total_numerator
×
220
        return self._actual_numerator
×
221

222
    @property
1✔
223
    def actual_denominator(self):
1✔
224
        if self._actual_denominator is None and self.type == IndicatorType.PERCENTAGE:
×
225
            self._actual_denominator = self.updates.total_denominator + self.contributors.total_denominator
×
226
        return self._actual_denominator
×
227

228
    @property
1✔
229
    def countries(self):
1✔
230
        if self._countries is None:
×
231
            self._countries = self.contributors.countries
×
232
        return self._countries
×
233

234
    @property
1✔
235
    def locations(self):
1✔
236
        if self._locations is None:
×
237
            location = self.project.primary_location
×
238
            self._locations = merge_unique(self.contributors.locations, [location])
×
239
        return self._locations
×
240

241
    @property
1✔
242
    def disaggregation_contributions(self):
1✔
243
        if self._disaggregation_contributions is None:
×
244
            self._disaggregation_contributions = self.contributors.disaggregations
×
245
        return self._disaggregation_contributions
×
246

247
    @property
1✔
248
    def disaggregation_targets(self):
1✔
249
        if self._disaggregation_targets is None:
×
250
            disaggregations = [
×
251
                DisaggregationTarget(t)
252
                for t in self._real.disaggregation_targets.all()
253
            ]
254
            self._disaggregation_targets = {(d.category, d.type): d for d in disaggregations}
×
255
        return self._disaggregation_targets
×
256

257
    def get_disaggregation_target_of(self, category, type):
1✔
258
        key = (category, type)
×
259
        if key not in self.disaggregation_targets:
×
260
            return None
×
261
        return ensure_decimal(self.disaggregation_targets[key].value)
×
262

263
    @property
1✔
264
    def disaggregation_contributions_view(self):
1✔
265
        if self._disaggregation_contributions_view is None:
×
266
            self._disaggregation_contributions_view = copy.deepcopy(self._project_disaggregations)
×
267
            for d in self.disaggregation_contributions.values():
×
268
                category = d['category']
×
269
                type = d['type']
×
270
                value = d['value']
×
271
                numerator = d['numerator']
×
272
                denominator = d['denominator']
×
273
                if category not in self._disaggregation_contributions_view or type not in self._disaggregation_contributions_view[category]:
×
274
                    continue
×
275
                self._disaggregation_contributions_view[category][type] = {
×
276
                    'value': value,
277
                    'numerator': numerator,
278
                    'denominator': denominator,
279
                }
280

281
        return self._disaggregation_contributions_view
×
282

283
    def get_disaggregation_contribution_of(self, category, type):
1✔
284
        key = (category, type)
×
285
        if key not in self.disaggregation_contributions:
×
286
            return None
×
287
        return self.disaggregation_contributions[key]['value']
×
288

289

290
class ContributorCollection(object):
1✔
291
    def __init__(self, nodes, type=IndicatorType.UNIT, project_disaggregations=None):
1✔
292
        self.nodes = nodes
×
293
        self.type = type
×
294
        self._project_disaggregations = project_disaggregations
×
295
        self._contributors = None
×
296
        self._total_value = None
×
297
        self._total_numerator = None
×
298
        self._total_denominator = None
×
299
        self._countries = None
×
300
        self._locations = None
×
301
        self._disaggregations = None
×
302

303
    @property
1✔
304
    def total_value(self):
1✔
305
        self._build()
×
306
        return self._total_value
×
307

308
    @property
1✔
309
    def total_numerator(self):
1✔
310
        self._build()
×
311
        return self._total_numerator
×
312

313
    @property
1✔
314
    def total_denominator(self):
1✔
315
        self._build()
×
316
        return self._total_denominator
×
317

318
    @property
1✔
319
    def countries(self):
1✔
320
        self._build()
×
321
        return self._countries
×
322

323
    @property
1✔
324
    def locations(self):
1✔
325
        self._build()
×
326
        return self._locations
×
327

328
    @property
1✔
329
    def disaggregations(self):
1✔
330
        self._build()
×
331
        return self._disaggregations
×
332

333
    def __iter__(self):
1✔
334
        self._build()
×
335
        return iter(self._contributors)
×
336

337
    def __len__(self):
1✔
338
        self._build()
×
339
        return len(self._contributors)
×
340

341
    def _build(self):
1✔
342
        if self._contributors is not None:
×
343
            return
×
344

345
        self._contributors = []
×
346
        self._countries = []
×
347
        self._locations = []
×
348
        self._disaggregations = {}
×
349
        if self.type == IndicatorType.PERCENTAGE:
×
350
            self._total_numerator = 0
×
351
            self._total_denominator = 0
×
352
        else:
353
            self._total_value = 0
×
354

355
        for node in self.nodes:
×
356
            contributor = Contributor(node['item'], node['children'], self.type, self._project_disaggregations)
×
357

358
            if not contributor.project.aggregate_to_parent or (
×
359
                ensure_decimal(contributor.actual_value) < 1 and len(contributor.updates) < 1
360
            ):
361
                continue
×
362

363
            self._contributors.append(contributor)
×
364
            self._countries = merge_unique(self._countries, contributor.contributing_countries)
×
365
            self._locations = merge_unique(self._locations, contributor.contributing_locations)
×
366

367
            # calculate values
368
            if self.type == IndicatorType.PERCENTAGE:
×
369
                self._total_numerator += contributor.actual_numerator
×
370
                self._total_denominator += contributor.actual_denominator
×
371
            else:
372
                self._total_value += ensure_decimal(contributor.actual_value)
×
373

374
            # calculate disaggregations
375
            for key in contributor.contributors.disaggregations:
×
376
                if key not in self._disaggregations:
×
377
                    self._disaggregations[key] = contributor.contributors.disaggregations[key].copy()
×
378
                else:
379
                    self._disaggregations[key]['value'] += contributor.contributors.disaggregations[key]['value']
×
380
            for key in contributor.updates.disaggregations:
×
381
                if key not in self._disaggregations:
×
382
                    self._disaggregations[key] = contributor.updates.disaggregations[key].copy()
×
383
                else:
384
                    self._disaggregations[key]['value'] += contributor.updates.disaggregations[key]['value']
×
385

386

387
class Contributor(object):
1✔
388
    def __init__(self, period, children=[], type=IndicatorType.UNIT, project_disaggregations=None):
1✔
389
        self.period = period
×
390
        self.children = children
×
391
        self.type = type
×
392
        self._project_disaggregations = project_disaggregations
×
393
        self._project = None
×
394
        self._country = None
×
395
        self._actual_numerator = None
×
396
        self._actual_denominator = None
×
397
        self._location = None
×
398
        self._updates = None
×
399
        self._contributors = None
×
400
        self._contributing_countries = None
×
401
        self._contributing_locations = None
×
402
        self._actual_comment = None
×
403
        self._target_value = None
×
404
        self._disaggregation_targets = None
×
405
        self._disaggregations_view = None
×
406

407
    @property
1✔
408
    def project(self):
1✔
409
        if self._project is None:
×
410
            self._project = self.period.indicator.result.project
×
411
        return self._project
×
412

413
    @property
1✔
414
    def contributors(self):
1✔
415
        if self._contributors is None:
×
416
            children = self.children if self.project.aggregate_children else []
×
417
            self._contributors = ContributorCollection(children, self.type, self._project_disaggregations)
×
418
        return self._contributors
×
419

420
    @property
1✔
421
    def updates(self):
1✔
422
        if self._updates is None:
×
423
            self._updates = UpdateCollection(self.period, self.type)
×
424
        return self._updates
×
425

426
    @cached_property
1✔
427
    def is_cumulative(self):
1✔
428
        return self.period.indicator.is_cumulative()
×
429

430
    @cached_property
1✔
431
    def actual_value(self):
1✔
432
        if self.type == IndicatorType.PERCENTAGE:
×
433
            return calculate_percentage(self.actual_numerator, self._actual_denominator)
×
434
        if self.is_cumulative and self.period.period_start and self.period.period_start > date.today():
×
435
            return 0
×
436
        return ensure_decimal(self.period.actual_value)
×
437

438
    @property
1✔
439
    def actual_numerator(self):
1✔
440
        if self._actual_numerator is None and self.type == IndicatorType.PERCENTAGE:
×
441
            self._actual_numerator = self.updates.total_numerator + self.contributors.total_numerator
×
442
        return self._actual_numerator
×
443

444
    @property
1✔
445
    def actual_denominator(self):
1✔
446
        if self._actual_denominator is None and self.type == IndicatorType.PERCENTAGE:
×
447
            self._actual_denominator = self.updates.total_denominator + self.contributors.total_denominator
×
448
        return self._actual_denominator
×
449

450
    @property
1✔
451
    def actual_comment(self):
1✔
452
        if self._actual_comment is None:
×
453
            self._actual_comment = self.period.actual_comment.split(' | ') \
×
454
                if self.period.actual_comment \
455
                else False
456
        return self._actual_comment or None
×
457

458
    @property
1✔
459
    def target_value(self):
1✔
460
        if self._target_value is None:
×
461
            self._target_value = ensure_decimal(self.period.target_value) \
×
462
                if self.type != IndicatorType.NARRATIVE \
463
                else self.period.target_value
464
        return self._target_value
×
465

466
    @property
1✔
467
    def disaggregation_targets(self):
1✔
468
        if self._disaggregation_targets is None:
×
469
            disaggregations = [
×
470
                DisaggregationTarget(t)
471
                for t in self.period.disaggregation_targets.all()
472
            ]
473
            self._disaggregation_targets = {(d.category, d.type): d for d in disaggregations}
×
474
        return self._disaggregation_targets
×
475

476
    def get_disaggregation_target_of(self, category, type):
1✔
477
        key = (category, type)
×
478
        if key not in self.disaggregation_targets:
×
479
            return None
×
480
        return ensure_decimal(self.disaggregation_targets[key].value)
×
481

482
    @property
1✔
483
    def country(self):
1✔
484
        if self._country is None:
×
485
            self._country = self.location.country if self.location else False
×
486
        return self._country or None
×
487

488
    @property
1✔
489
    def contributing_countries(self):
1✔
490
        if self._contributing_countries is None:
×
491
            self._contributing_countries = merge_unique(self.contributors.countries, [self.country])
×
492
        return self._contributing_countries
×
493

494
    @property
1✔
495
    def location(self):
1✔
496
        if self._location is None:
×
497
            self._location = self.project.primary_location or False
×
498
        return self._location or None
×
499

500
    @property
1✔
501
    def contributing_locations(self):
1✔
502
        if self._contributing_locations is None:
×
503
            self._contributing_locations = merge_unique(self.contributors.locations, [self.location])
×
504
        return self._contributing_locations
×
505

506
    @property
1✔
507
    def disaggregations_view(self):
1✔
508
        if self._disaggregations_view is None:
×
509
            self._disaggregations_view = copy.deepcopy(self._project_disaggregations)
×
510
            for d in self.updates.disaggregations.values():
×
511
                category = d['category']
×
512
                type = d['type']
×
513
                value = d['value']
×
514
                numerator = d['numerator']
×
515
                denominator = d['denominator']
×
516
                if category not in self._disaggregations_view or type not in self._disaggregations_view[category]:
×
517
                    continue
×
518
                self._disaggregations_view[category][type] = {
×
519
                    'value': value,
520
                    'numerator': numerator,
521
                    'denominator': denominator,
522
                }
523

524
        return self._disaggregations_view
×
525

526
    def get_disaggregation_of(self, category, type):
1✔
527
        key = (category, type)
×
528
        if key not in self.updates.disaggregations:
×
529
            return None
×
530
        return self.updates.disaggregations[key]['value']
×
531

532

533
class UpdateCollection(object):
1✔
534
    def __init__(self, period, type):
1✔
535
        self.period = period
1✔
536
        self.type = type
1✔
537
        self._updates = None
1✔
538
        self._total_value = None
1✔
539
        self._total_numerator = None
1✔
540
        self._total_denominator = None
1✔
541
        self._disaggregations = None
1✔
542

543
    @property
1✔
544
    def total_value(self):
1✔
545
        self._build()
1✔
546
        return self._total_value
1✔
547

548
    @property
1✔
549
    def total_numerator(self):
1✔
550
        self._build()
1✔
551
        return self._total_numerator
1✔
552

553
    @property
1✔
554
    def total_denominator(self):
1✔
555
        self._build()
1✔
556
        return self._total_denominator
1✔
557

558
    @property
1✔
559
    def disaggregations(self):
1✔
560
        self._build()
1✔
561
        return self._disaggregations
1✔
562

563
    def __iter__(self):
1✔
564
        self._build()
×
565
        return iter(self._updates)
×
566

567
    def __len__(self):
1✔
568
        self._build()
×
569
        return len(self._updates)
×
570

571
    def _build(self):
1✔
572
        if self._updates is not None:
1✔
573
            return
1✔
574

575
        self._updates = []
1✔
576
        self._total_value = 0
1✔
577
        if self.type == IndicatorType.PERCENTAGE:
1✔
578
            self._total_numerator = 0
1✔
579
            self._total_denominator = 0
1✔
580
        self._disaggregations = {}
1✔
581

582
        for update in self.period.data.all():
1✔
583
            self._updates.append(UpdateProxy(update))
1✔
584
            if update.status != IndicatorPeriodData.STATUS_APPROVED_CODE:
1✔
585
                continue
×
586
            if self.type == IndicatorType.PERCENTAGE:
1✔
587
                if update.numerator is not None and update.denominator is not None:
1✔
588
                    self._total_numerator += update.numerator
1✔
589
                    self._total_denominator += update.denominator
1✔
590
            elif update.value:
1✔
591
                self._total_value += update.value
1✔
592

593
            for d in update.disaggregations.all():
1✔
594
                key = (d.dimension_value.name.name, d.dimension_value.value)
1✔
595
                if key not in self._disaggregations:
1✔
596
                    self._disaggregations[key] = {
1✔
597
                        'category': d.dimension_value.name.name,
598
                        'type': d.dimension_value.value,
599
                        'value': 0,
600
                        'numerator': d.numerator,
601
                        'denominator': d.denominator,
602
                    }
603

604
                self._disaggregations[key]['value'] += ensure_decimal(d.value)
1✔
605

606
        if self.type == IndicatorType.PERCENTAGE and self._total_denominator > 0:
1✔
607
            self._total_value = calculate_percentage(self._total_numerator, self._total_denominator)
1✔
608

609

610
class UpdateProxy(ObjectReaderProxy):
1✔
611
    pass
1✔
612

613

614
class DisaggregationTarget(ObjectReaderProxy):
1✔
615
    def __init__(self, target):
1✔
616
        super().__init__(target)
×
617
        self._category = None
×
618
        self._type = None
×
619

620
    @property
1✔
621
    def category(self):
1✔
622
        if self._category is None:
×
623
            self._category = self.dimension_value.name.name
×
624
        return self._category
×
625

626
    @property
1✔
627
    def type(self):
1✔
628
        if self._type is None:
×
629
            self._type = self.dimension_value.value
×
630
        return self._type
×
631

632

633
def _aggregate_period_targets(period, children):
1✔
634
    aggregate = ensure_decimal(period.target_value)
×
635
    for node in children:
×
636
        aggregate += _aggregate_period_targets(node['item'], node.get('children', []))
×
637
    return aggregate
×
638

639

640
def _aggregate_indicator_targets(period, children):
1✔
641
    aggregate = ensure_decimal(period.indicator.target_value)
×
642
    for node in children:
×
643
        aggregate += _aggregate_indicator_targets(node['item'], node.get('children', []))
×
644
    return aggregate
×
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

© 2024 Coveralls, Inc