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

collective / collective.documentgenerator / 5723554244

pending completion
5723554244

push

github

web-flow
Add `DOCUMENTGENERATOR_LOG_PARAMETERS` environment variable that can be used to log request form parameters with collective.fingerpointing (#59)

6 of 6 new or added lines in 1 file covered. (100.0%)

2125 of 2325 relevant lines covered (91.4%)

0.91 hits per line

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

94.4
/src/collective/documentgenerator/browser/generation_view.py
1
# -*- coding: utf-8 -*-
2

3
import mimetypes
1✔
4
import os
1✔
5
import unicodedata
1✔
6

7
import pkg_resources
1✔
8
from AccessControl import Unauthorized
1✔
9
from appy.pod.renderer import CsvOptions, Renderer
1✔
10
from appy.pod.styles_manager import TableProperties
1✔
11
from plone import api
1✔
12
from plone.app.uuid.utils import uuidToObject
1✔
13
from plone.i18n.normalizer.interfaces import IFileNameNormalizer
1✔
14
from Products.CMFPlone.utils import base_hasattr, safe_unicode
1✔
15
from Products.Five import BrowserView
1✔
16
from StringIO import StringIO
1✔
17
from zope.annotation.interfaces import IAnnotations
1✔
18
from zope.component import getMultiAdapter, queryAdapter, queryUtility
1✔
19

20
from collective.documentgenerator import config, utils
1✔
21
from collective.documentgenerator.content.pod_template import IPODTemplate
1✔
22
from collective.documentgenerator.interfaces import (
1✔
23
    CyclicMergeTemplatesException, IDocumentFactory, PODTemplateNotFoundError)
24
from collective.documentgenerator.utils import remove_tmp_file
1✔
25
from collective.documentgenerator.utils import temporary_file_name
1✔
26
from .. import _
1✔
27

28
HAS_FINGERPOINTING = None
1✔
29

30
try:
1✔
31
    pkg_resources.get_distribution('collective.fingerpointing')
1✔
32
except pkg_resources.DistributionNotFound:
×
33
    HAS_FINGERPOINTING = False
×
34
else:
35
    HAS_FINGERPOINTING = True
1✔
36

37

38
class DocumentGenerationView(BrowserView):
1✔
39
    """
40
    Document generation view.
41
    """
42

43
    def __init__(self, context, request):
1✔
44
        self.context = context
1✔
45
        self.request = request
1✔
46
        self.pod_template = None
1✔
47
        self.output_format = None
1✔
48

49
    def __call__(self, template_uid='', output_format='', **kwargs):
1✔
50
        self.pod_template, self.output_format = self._get_base_args(template_uid, output_format)
1✔
51
        return self.generate_and_download_doc(self.pod_template, self.output_format, **kwargs)
1✔
52

53
    def _get_base_args(self, template_uid, output_format):
1✔
54
        template_uid = template_uid or self.get_pod_template_uid()
1✔
55
        pod_template = self.get_pod_template(template_uid)
1✔
56
        output_format = output_format or self.get_generation_format()
1✔
57
        if not output_format:
1✔
58
            raise Exception("No 'output_format' found to generate this document")
1✔
59

60
        return pod_template, output_format
1✔
61

62
    def generate_and_download_doc(self, pod_template, output_format, **kwargs):
1✔
63
        """
64
        Generate a document of format 'output_format' from the template
65
        'pod_template' and return it as a downloadable file.
66
        """
67
        if HAS_FINGERPOINTING:
1✔
68
            from collective.fingerpointing.config import AUDIT_MESSAGE
1✔
69
            from collective.fingerpointing.logger import log_info
1✔
70
            from collective.fingerpointing.utils import get_request_information
1✔
71

72
            # add logging message to fingerpointing log
73
            user, ip = get_request_information()
1✔
74
            action = 'generate_document'
1✔
75
            extras = 'context={0} pod_template={1} output_format={2}'.format(
1✔
76
                '/'.join(self.context.getPhysicalPath()),
77
                '/'.join(pod_template.getPhysicalPath()),
78
                output_format)
79
            allowed_parameters = filter(
1✔
80
                None,
81
                os.getenv("DOCUMENTGENERATOR_LOG_PARAMETERS", "").split(",")
82
            )
83
            if allowed_parameters:
1✔
84
                for key, value in self.request.form.items():
×
85
                    if key in allowed_parameters:
×
86
                        extras = "{0} {1}={2}".format(extras, key, value)
×
87
            log_info(AUDIT_MESSAGE.format(user, ip, action, extras))
1✔
88

89
        doc, doc_name, gen_context = self._generate_doc(pod_template, output_format, **kwargs)
1✔
90
        self._set_header_response(doc_name)
1✔
91
        return doc
1✔
92

93
    def _generate_doc(self, pod_template, output_format, **kwargs):
1✔
94
        """
95
        Generate a document of format 'output_format' from the template
96
        'pod_template'.
97
        """
98
        if not pod_template.can_be_generated(self.context):
1✔
99
            raise Unauthorized('You are not allowed to generate this document.')
1✔
100

101
        if output_format not in pod_template.get_available_formats():
1✔
102
            raise Exception(
1✔
103
                "Asked output format '{0}' "
104
                "is not available for template '{1}'!".format(
105
                    output_format,
106
                    pod_template.getId()
107
                )
108
            )
109

110
        # subtemplates should not refer to each other in a cyclic way.
111
        self._check_cyclic_merges(pod_template)
1✔
112

113
        # Recursive generation of the document and all its subtemplates.
114
        document_path, gen_context = self._recursive_generate_doc(pod_template, output_format, **kwargs)
1✔
115

116
        rendered_document = open(document_path, 'rb')
1✔
117
        rendered = rendered_document.read()
1✔
118
        rendered_document.close()
1✔
119
        remove_tmp_file(document_path)
1✔
120
        filename = self._get_filename()
1✔
121
        return rendered, filename, gen_context
1✔
122

123
    def _get_filename(self):
1✔
124
        """ """
125
        # we limit filename to 120 characters
126
        first_part = u'{0} {1}'.format(self.pod_template.title, safe_unicode(self.context.Title()))
1✔
127
        # replace unicode special characters with ascii equivalent value
128
        first_part = unicodedata.normalize('NFKD', first_part).encode('ascii', 'ignore')
1✔
129
        util = queryUtility(IFileNameNormalizer)
1✔
130
        # remove '-' from first_part because it is handled by cropName that manages max_length
131
        # and it behaves weirdly if it encounters '-'
132
        # moreover avoid more than one blank space at a time
133
        first_part = u' '.join(util.normalize(first_part).replace(u'-', u' ').split()).strip()
1✔
134
        filename = '{0}.{1}'.format(util.normalize(first_part, max_length=120), self.output_format)
1✔
135
        return filename
1✔
136

137
    def _recursive_generate_doc(self, pod_template, output_format, **kwargs):
1✔
138
        """
139
        Generate a document recursively by starting to generate all its
140
        subtemplates before merging them in the final document.
141
        Return the file path of the generated document.
142
        """
143
        sub_templates = pod_template.get_templates_to_merge()
1✔
144
        sub_documents = {}
1✔
145
        for context_name, (sub_pod, do_rendering) in sub_templates.iteritems():
1✔
146
            # Force the subtemplate output_format to 'odt' because appy.pod
147
            # can only merge documents in this format.
148
            if do_rendering:
1✔
149
                sub_documents[context_name] = self._recursive_generate_doc(
1✔
150
                    pod_template=sub_pod,
151
                    output_format='odt'
152
                )[0]
153
            else:
154
                sub_documents[context_name] = sub_pod
1✔
155

156
        document_path, gen_context = self._render_document(pod_template, output_format, sub_documents, **kwargs)
1✔
157

158
        return document_path, gen_context
1✔
159

160
    def get_pod_template(self, template_uid):
1✔
161
        """
162
        Return the default PODTemplate that will be used when calling
163
        this view.
164
        """
165
        catalog = api.portal.get_tool('portal_catalog')
1✔
166

167
        template_brains = catalog.unrestrictedSearchResults(
1✔
168
            object_provides=IPODTemplate.__identifier__,
169
            UID=template_uid
170
        )
171
        if not template_brains:
1✔
172
            raise PODTemplateNotFoundError(
1✔
173
                "Couldn't find POD template with UID '{0}'".format(template_uid)
174
            )
175

176
        template_path = template_brains[0].getPath()
1✔
177
        pod_template = self.context.unrestrictedTraverse(template_path)
1✔
178
        return pod_template
1✔
179

180
    def get_pod_template_uid(self):
1✔
181
        template_uid = self.request.get('template_uid', '')
1✔
182
        return template_uid
1✔
183

184
    def get_generation_format(self):
1✔
185
        """
186
        Return the default document output format that will be used
187
        when calling this view.
188
        """
189
        output_format = self.request.get('output_format')
1✔
190
        return output_format
1✔
191

192
    def _render_document(self, pod_template, output_format, sub_documents, raiseOnError=False, **kwargs):
1✔
193
        """
194
        Render a single document of type 'output_format' using the odt file
195
        'document_template' as the generation template.
196
        Subdocuments is a dictionnary of previously generated subtemplate
197
        that will be merged into the current generated document.
198
        """
199
        document_template = pod_template.get_file()
1✔
200
        temp_filename = temporary_file_name('.{extension}'.format(extension=output_format))
1✔
201

202
        # Prepare rendering context
203
        helper_view = self.get_generation_context_helper()
1✔
204
        generation_context = self._get_generation_context(helper_view, pod_template=pod_template)
1✔
205

206
        # enrich the generation context with previously generated documents
207
        utils.update_dict_with_validation(generation_context, sub_documents,
1✔
208
                                          _("Error when merging merge_templates in generation context"))
209

210
        # enable optimalColumnWidths if enabled in the config and/or on ConfigurablePodTemplate
211
        stylesMapping = {}
1✔
212
        optimalColumnWidths = "OCW_.*"
1✔
213
        distributeColumns = "DC_.*"
1✔
214

215
        column_modifier = pod_template.get_column_modifier()
1✔
216
        if column_modifier == -1:
1✔
217
            column_modifier = config.get_column_modifier()
1✔
218

219
        if column_modifier == 'disabled':
1✔
220
            optimalColumnWidths = False
1✔
221
            distributeColumns = False
1✔
222
        else:
223
            stylesMapping = {
1✔
224
                'table': TableProperties(columnModifier=column_modifier != 'nothing' and column_modifier or None)}
225

226
        # if raiseOnError is not enabled, enabled it in the config excepted if user is a Manager
227
        # and currently generated document use odt format
228
        if not raiseOnError:
1✔
229
            if config.get_raiseOnError_for_non_managers():
1✔
230
                raiseOnError = True
1✔
231
                if 'Manager' in api.user.get_roles() and output_format == 'odt':
1✔
232
                    raiseOnError = False
1✔
233

234
        # stylesMapping.update({'para[class=None, parent != cell]': 'texte_delibe',
235
        #                       'para[class=xSmallText, parent != cell]': 'bodyXSmall',
236
        #                       'para[class=smallText, parent != cell]': 'bodySmall',
237
        #                       'para[class=largeText, parent != cell]': 'bodyLarge',
238
        #                       'para[class=xLargeText, parent != cell]': 'bodyXLarge',
239
        #                       'para[class=indentation, parent != cell]': 'bodyIndentation',
240
        #                       'para[class=None, parent = cell]': 'cell_delibe',
241
        #                       'table': TableProperties(cellContentStyle='cell_delibe'),
242
        #                       'para[class=xSmallText, parent = cell]': 'cellXSmall',
243
        #                       'para[class=smallText, parent = cell]': 'cellSmall',
244
        #                       'para[class=largeText, parent = cell]': 'cellLarge',
245
        #                       'para[class=xLargeText, parent = cell]': 'cellXLarge',
246
        #                       'para[class=indentation, parent = cell]': 'cellIndentation',
247
        #                       })
248
        # stylesMapping.update({'para[class=None,parent!=cell]': 'texte_delibe',
249
        #                       'para[class=xSmallText,parent!=cell]': 'bodyXSmall',
250
        #                       'para[class=smallText,parent!=cell]': 'bodySmall',
251
        #                       'para[class=largeText,parent!=cell]': 'bodyLarge',
252
        #                       'para[class=xLargeText,parent!=cell]': 'bodyXLarge',
253
        #                       'para[class=indentation,parent!=cell]': 'bodyIndentation',
254
        #                       'para[class=xSmallText,parent=cell]': 'cellXSmall',
255
        #                       'para[class=smallText,parent=cell]': 'cellSmall',
256
        #                       'para[class=largeText,parent=cell]': 'cellLarge',
257
        #                       'para[class=xLargeText,parent=cell]': 'cellXLarge',
258
        #                       'para[class=indentation,parent=cell]': 'cellIndentation',
259
        #                       })
260

261
        csvOptions = None
1✔
262

263
        if output_format == "csv":
1✔
264
            csvOptions = CsvOptions(fieldSeparator=pod_template.csv_field_delimiter,
×
265
                                    textDelimiter=pod_template.csv_string_delimiter)
266
        renderer = Renderer(
1✔
267
            StringIO(document_template.data),
268
            generation_context,
269
            temp_filename,
270
            pythonWithUnoPath=config.get_uno_path(),
271
            ooServer=config.get_oo_server(),
272
            ooPort=config.get_oo_port_list(),
273
            raiseOnError=raiseOnError,
274
            imageResolver=api.portal.get(),
275
            forceOoCall=True,
276
            html=True,
277
            optimalColumnWidths=optimalColumnWidths,
278
            distributeColumns=distributeColumns,
279
            stylesMapping=stylesMapping,
280
            stream=config.get_use_stream(),
281
            csvOptions=csvOptions,
282
            # deleteTempFolder=False,
283
            **kwargs
284
        )
285

286
        # it is only now that we can initialize helper view's appy pod renderer
287
        all_helper_views = self.get_views_for_appy_renderer(generation_context, helper_view)
1✔
288
        for view in all_helper_views:
1✔
289
            view._set_appy_renderer(renderer)
1✔
290

291
        renderer.run()
1✔
292

293
        # return also generation_context to test ist content in tests
294
        return temp_filename, generation_context
1✔
295

296
    def _get_context_variables(self, pod_template):
1✔
297
        if base_hasattr(pod_template, 'get_context_variables'):
1✔
298
            return pod_template.get_context_variables()
1✔
299
        return {}
1✔
300

301
    def _get_generation_context(self, helper_view, pod_template):
1✔
302
        """
303
        Return the generation context for the current document.
304
        """
305
        generation_context = self.get_base_generation_context(helper_view, pod_template)
1✔
306
        utils.update_dict_with_validation(generation_context,
1✔
307
                                          {'context': getattr(helper_view, 'context', None),
308
                                           'portal': api.portal.get(),
309
                                           'view': helper_view},
310
                                          _("Error when merging helper_view in generation context"))
311
        utils.update_dict_with_validation(generation_context, self._get_context_variables(pod_template),
1✔
312
                                          _("Error when merging context_variables in generation context"))
313
        return generation_context
1✔
314

315
    def get_base_generation_context(self, helper_view, pod_template):
1✔
316
        """
317
        Override this method to provide your own generation context.
318
        """
319
        return {}
1✔
320

321
    def get_generation_context_helper(self):
1✔
322
        """
323
        Return the default helper view used for document generation.
324
        """
325
        helper_view = getMultiAdapter((self.context, self.request), name='document_generation_helper_view')
1✔
326
        helper_view.pod_template = self.pod_template
1✔
327
        helper_view.output_format = self.output_format
1✔
328
        return helper_view
1✔
329

330
    def get_views_for_appy_renderer(self, generation_context, helper_view):
1✔
331
        views = []
1✔
332
        if 'view' in generation_context:
1✔
333
            # helper_view has maybe changed in generation context getter
334
            views.append(generation_context['view'])
1✔
335
        else:
336
            views.append(helper_view)
×
337

338
        return views
1✔
339

340
    def _set_header_response(self, filename):
1✔
341
        """
342
        Tell the browser that the resulting page contains ODT.
343
        """
344
        response = self.request.RESPONSE
1✔
345
        mimetype = mimetypes.guess_type(filename)[0]
1✔
346
        response.setHeader('Content-type', mimetype)
1✔
347
        response.setHeader(
1✔
348
            'Content-disposition',
349
            u'inline;filename="{}"'.format(filename).encode('utf-8')
350
        )
351

352
    def _check_cyclic_merges(self, pod_template):
1✔
353
        """
354
        Check if the template 'pod_template' has subtemplates referring to each
355
        other in a cyclic way.
356
        """
357

358
        def traverse_check(pod_template, path):
1✔
359

360
            if pod_template in path:
1✔
361
                path.append(pod_template)
1✔
362
                start_cycle = path.index(pod_template)
1✔
363
                start_msg = ' --> '.join(
1✔
364
                    ['"{}" {}'.format(t.Title(), '/'.join(t.getPhysicalPath())) for t in path[:start_cycle]]
365
                )
366
                cycle_msg = ' <--> '.join(
1✔
367
                    ['"{}" {}'.format(t.Title(), '/'.join(t.getPhysicalPath())) for t in path[start_cycle:]]
368
                )
369
                msg = '{} -> CYCLE:\n{}'.format(start_msg, cycle_msg)
1✔
370
                raise CyclicMergeTemplatesException(msg)
1✔
371

372
            new_path = list(path)
1✔
373
            new_path.append(pod_template)
1✔
374

375
            sub_templates = pod_template.get_templates_to_merge()
1✔
376

377
            for name, (sub_template, do_rendering) in sub_templates.iteritems():
1✔
378
                traverse_check(sub_template, new_path)
1✔
379

380
        traverse_check(pod_template, [])
1✔
381

382

383
class PersistentDocumentGenerationView(DocumentGenerationView):
1✔
384
    """
385
    Persistent document generation view.
386
    """
387

388
    def __call__(self, template_uid='', output_format='', generated_doc_title=''):
1✔
389
        self.generated_doc_title = generated_doc_title
1✔
390
        self.pod_template, self.output_format = self._get_base_args(template_uid, output_format)
1✔
391
        persisted_doc = self.generate_persistent_doc(self.pod_template, self.output_format)
1✔
392
        self.redirects(persisted_doc)
1✔
393

394
    def add_mailing_infos(self, doc, gen_context):
1✔
395
        """ store mailing informations on generated doc """
396
        annot = IAnnotations(doc)
1✔
397
        if 'mailed_data' in gen_context or 'mailing_list' in gen_context:
1✔
398
            annot['documentgenerator'] = {'need_mailing': False, 'template_uid': self.pod_template.UID()}
1✔
399
        else:
400
            annot['documentgenerator'] = {'need_mailing': True, 'template_uid': self.pod_template.UID(),
1✔
401
                                          'output_format': self.output_format, 'context_uid': self.context.UID()}
402

403
    def _get_title(self, doc_name, gen_context):
1✔
404
        splitted_name = doc_name.split('.')
1✔
405
        title = self.generated_doc_title or self.pod_template.title
1✔
406
        extension = splitted_name[-1]
1✔
407
        return safe_unicode(title), extension
1✔
408

409
    def generate_persistent_doc(self, pod_template, output_format):
1✔
410
        """
411
        Generate a document of format 'output_format' from the template
412
        'pod_template' and persist it by creating a File containing the
413
        generated document on the current context.
414
        """
415

416
        doc, doc_name, gen_context = self._generate_doc(pod_template, output_format)
1✔
417

418
        title, extension = self._get_title(doc_name, gen_context)
1✔
419

420
        factory = queryAdapter(self.context, IDocumentFactory)
1✔
421

422
        #  Bypass any File creation permission of the user. If the user isnt
423
        #  supposed to save generated document on the site, then its the permission
424
        #  to call the generation view that should be changed.
425
        with api.env.adopt_roles(['Manager']):
1✔
426
            persisted_doc = factory.create(doc_file=doc, title=title, extension=extension)
1✔
427

428
        # store informations on persisted doc
429
        self.add_mailing_infos(persisted_doc, gen_context)
1✔
430

431
        return persisted_doc
1✔
432

433
    def redirects(self, persisted_doc):
1✔
434
        """
435
        Redirects to the created document.
436
        """
437
        if config.HAS_PLONE_5:
1✔
438
            filename = persisted_doc.file.filename
1✔
439
        else:
440
            filename = persisted_doc.getFile().filename
×
441
        self._set_header_response(filename)
1✔
442
        response = self.request.response
1✔
443
        return response.redirect(persisted_doc.absolute_url() + '/external_edit')
1✔
444

445
    def mailing_related_generation_context(self, helper_view, gen_context):
1✔
446
        """
447
            Add mailing related information in generation context
448
        """
449
        # We add mailed_data if we have only one element in mailing list
450
        mailing_list = helper_view.mailing_list(gen_context)
1✔
451
        if len(mailing_list) == 0:
1✔
452
            utils.update_dict_with_validation(gen_context, {'mailed_data': None},
×
453
                                              _("Error when merging mailed_data in generation context"))
454
        elif len(mailing_list) == 1:
1✔
455
            utils.update_dict_with_validation(gen_context, {'mailed_data': mailing_list[0]},
1✔
456
                                              _("Error when merging mailed_data in generation context"))
457

458
    def _get_generation_context(self, helper_view, pod_template):
1✔
459
        """ """
460
        gen_context = super(PersistentDocumentGenerationView, self)._get_generation_context(helper_view, pod_template)
1✔
461
        self.mailing_related_generation_context(helper_view, gen_context)
1✔
462
        return gen_context
1✔
463

464

465
class MailingLoopPersistentDocumentGenerationView(PersistentDocumentGenerationView):
1✔
466
    """
467
        Mailing persistent document generation view.
468
        This view use a MailingLoopTemplate to loop on a document when replacing some variables in.
469
    """
470

471
    def __call__(self, document_uid='', document_url_path='', generated_doc_title=''):
1✔
472
        self.generated_doc_title = generated_doc_title
1✔
473
        document_uid = document_uid or self.request.get('document_uid', '')
1✔
474
        document_url_path = document_url_path or self.request.get('document_url_path', '')
1✔
475
        if not document_uid and not document_url_path:
1✔
476
            raise Exception("No 'document_uid' or 'url_path' found to generate this document")
×
477
        elif document_url_path:
1✔
478
            site = api.portal.get()
1✔
479
            self.document = site.restrictedTraverse(document_url_path)
1✔
480
        else:
481
            self.document = uuidToObject(document_uid)
1✔
482
        if not self.document:
1✔
483
            raise Exception("Cannot find document with UID '{0}'".format(document_uid))
×
484
        self.pod_template, self.output_format = self._get_base_args('', '')
1✔
485
        persisted_doc = self.generate_persistent_doc(self.pod_template, self.output_format)
1✔
486
        self.redirects(persisted_doc)
1✔
487

488
    def _get_base_args(self, template_uid, output_format):
1✔
489
        annot = IAnnotations(self.document).get('documentgenerator', '')
1✔
490
        if not annot or 'template_uid' not in annot:
1✔
491
            raise Exception("Cannot find 'template_uid' on document '{0}'".format(self.document.absolute_url()))
×
492
        self.orig_template = self.get_pod_template(annot['template_uid'])
1✔
493
        if (not base_hasattr(self.orig_template, 'mailing_loop_template') or
1✔
494
                not self.orig_template.mailing_loop_template):
495
            raise Exception("Cannot find 'mailing_loop_template' on template '{0}'".format(
×
496
                self.orig_template.absolute_url()))
497
        loop_template = self.get_pod_template(self.orig_template.mailing_loop_template)
1✔
498

499
        if 'output_format' not in annot:
1✔
500
            raise Exception("No 'output_format' found to generate this document")
×
501
        return loop_template, annot['output_format']
1✔
502

503
    def mailing_related_generation_context(self, helper_view, gen_context):
1✔
504
        """
505
            Add mailing related information in generation context
506
        """
507
        # We do nothing here because we have to call mailing_list after original template context variable inclusion
508

509
    def _get_generation_context(self, helper_view, pod_template):
1✔
510
        """ """
511
        gen_context = super(MailingLoopPersistentDocumentGenerationView, self). \
1✔
512
            _get_generation_context(helper_view, pod_template)
513
        # add variable context from original template
514
        utils.update_dict_with_validation(gen_context, self._get_context_variables(self.orig_template),
1✔
515
                                          _("Error when merging context_variables in generation context"))
516
        # add mailing list in generation context
517
        dic = {'mailing_list': helper_view.mailing_list(gen_context), 'mailed_doc': self.document}
1✔
518
        utils.update_dict_with_validation(gen_context, dic,
1✔
519
                                          _("Error when merging mailing_list in generation context"))
520
        return gen_context
1✔
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

© 2026 Coveralls, Inc