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

openmrs / openmrs-core / 24403205906

14 Apr 2026 01:59PM UTC coverage: 63.88% (+0.04%) from 63.836%
24403205906

push

github

ibacher
Fix compilation issue

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

193 existing lines in 14 files now uncovered.

22202 of 34756 relevant lines covered (63.88%)

0.64 hits per line

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

77.97
/api/src/main/java/org/openmrs/Obs.java
1
/**
2
 * This Source Code Form is subject to the terms of the Mozilla Public License,
3
 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
4
 * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
5
 * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
6
 *
7
 * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
8
 * graphic logo is a trademark of OpenMRS Inc.
9
 */
10
package org.openmrs;
11

12
import java.text.DateFormat;
13
import java.text.DecimalFormat;
14
import java.text.NumberFormat;
15
import java.text.ParseException;
16
import java.text.SimpleDateFormat;
17
import java.util.Date;
18
import java.util.HashSet;
19
import java.util.LinkedHashSet;
20
import java.util.Locale;
21
import java.util.Set;
22

23
import org.apache.commons.lang3.StringUtils;
24
import org.openmrs.annotation.AllowDirectAccess;
25
import org.openmrs.api.APIException;
26
import org.openmrs.api.context.Context;
27
import org.openmrs.obs.ComplexData;
28
import org.openmrs.obs.ComplexObsHandler;
29
import org.openmrs.util.Format;
30
import org.openmrs.util.Format.FORMAT_TYPE;
31
import org.openmrs.util.OpenmrsUtil;
32
import org.slf4j.Logger;
33
import org.slf4j.LoggerFactory;
34

35
/**
36
 * An observation is a single unit of clinical information. <br>
37
 * <br>
38
 * Observations are collected and grouped together into one Encounter (one visit). Obs can be
39
 * grouped in a hierarchical fashion. <br>
40
 * <br>
41
 * <p>
42
 * The {@link #getObsGroup()} method returns an optional parent. That parent object is also an Obs.
43
 * The parent Obs object knows about its child objects through the {@link #getGroupMembers()}
44
 * method.
45
 * </p>
46
 * <p>
47
 * (Multi-level hierarchies are achieved by an Obs parent object being a member of another Obs
48
 * (grand)parent object) Read up on the obs table: http://openmrs.org/wiki/Obs_Table_Primer In an
49
 * OpenMRS installation, there may be an occasion need to change an Obs.
50
 * </p>
51
 * <p>
52
 * For example, a site may decide to replace a concept in the dictionary with a more specific set of
53
 * concepts. An observation is part of the official record of an encounter. There may be legal,
54
 * ethical, and auditing consequences from altering a record. It is recommended that you create a
55
 * new Obs and void the old one:
56
 * </p>
57
 * Obs newObs = Obs.newInstance(oldObs); //copies values from oldObs
58
 * newObs.setPreviousVersion(oldObs);
59
 * Context.getObsService().saveObs(newObs,"Your reason for the change here");
60
 * Context.getObsService().voidObs(oldObs, "Your reason for the change here");
61
 * 
62
 * @see Encounter
63
 */
64
public class Obs extends BaseFormRecordableOpenmrsData {
65
        
66
        /**
67
         * @since 2.1.0
68
         */
69
        public enum Interpretation {
1✔
70
                NORMAL, ABNORMAL, CRITICALLY_ABNORMAL, NEGATIVE, POSITIVE, CRITICALLY_LOW, LOW, HIGH, CRITICALLY_HIGH, VERY_SUSCEPTIBLE, SUSCEPTIBLE, INTERMEDIATE, RESISTANT, SIGNIFICANT_CHANGE_DOWN, SIGNIFICANT_CHANGE_UP, OFF_SCALE_LOW, OFF_SCALE_HIGH
1✔
71
        }
72
        
73
        /**
74
         * @since 2.1.0
75
         */
76
        public enum Status {
1✔
77
                PRELIMINARY, FINAL, AMENDED
1✔
78
        }
79
        
80
        private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm";
81
        
82
        private static final String TIME_PATTERN = "HH:mm";
83
        
84
        private static final String DATE_PATTERN = "yyyy-MM-dd";
85
        
86
        public static final long serialVersionUID = 112342333L;
87
        
88
        private static final Logger log = LoggerFactory.getLogger(Obs.class);
1✔
89
        
90
        protected Integer obsId;
91
        
92
        protected Concept concept;
93
        
94
        protected Date obsDatetime;
95
        
96
        protected String accessionNumber;
97
        
98
        /**
99
         * The "parent" of this obs. It is the grouping that brings other obs together. note:
100
         * obsGroup.getConcept().isSet() should be true This will be non-null if this obs is a member of
101
         * another groupedObs
102
         * 
103
         * @see #isObsGrouping() (??)
104
         */
105
        protected Obs obsGroup;
106
        
107
        /**
108
         * The list of obs grouped under this obs.
109
         */
110
        @AllowDirectAccess
111
        protected Set<Obs> groupMembers;
112
        
113
        protected Concept valueCoded;
114
        
115
        protected ConceptName valueCodedName;
116
        
117
        protected Drug valueDrug;
118
        
119
        protected Integer valueGroupId;
120
        
121
        protected Date valueDatetime;
122
        
123
        protected Double valueNumeric;
124
        
125
        protected String valueModifier;
126
        
127
        protected String valueText;
128
        
129
        protected String valueComplex;
130
        
131
        // ComplexData is not persisted in the database.
132
        protected transient ComplexData complexData;
133
        
134
        protected String comment;
135
        
136
        protected transient Integer personId;
137
        
138
        protected Person person;
139
        
140
        protected Order order;
141
        
142
        protected Location location;
143
        
144
        protected Encounter encounter;
145
        
146
        private Obs previousVersion;
147
        
148
        private Boolean dirty = Boolean.FALSE;
1✔
149
        
150
        private Interpretation interpretation;
151
        
152
        private Status status = Status.FINAL;
1✔
153
        
154
        /** default constructor */
155
        public Obs() {
1✔
156
        }
1✔
157
        
158
        /**
159
         * Required parameters constructor A value is also required, but that can be one of: valueCoded,
160
         * valueDrug, valueNumeric, or valueText
161
         * 
162
         * @param person The Person this obs is acting on
163
         * @param question The question concept this obs is related to
164
         * @param obsDatetime The time this obs took place
165
         * @param location The location this obs took place
166
         */
167
        public Obs(Person person, Concept question, Date obsDatetime, Location location) {
1✔
168
                this.person = person;
1✔
169
                if (person != null) {
1✔
170
                        this.personId = person.getPersonId();
1✔
171
                }
172
                this.concept = question;
1✔
173
                this.obsDatetime = obsDatetime;
1✔
174
                this.location = location;
1✔
175
        }
1✔
176
        
177
        /** constructor with id */
178
        public Obs(Integer obsId) {
1✔
179
                this.obsId = obsId;
1✔
180
        }
1✔
181
        
182
        /**
183
         * This is an equivalent to a copy constructor. Creates a new copy of the given
184
         * <code>obsToCopy</code> with a null obs id
185
         * 
186
         * @param obsToCopy The Obs that is going to be copied
187
         * @return a new Obs object with all the same attributes as the given obs
188
         */
189
        public static Obs newInstance(Obs obsToCopy) {
190
                Obs newObs = new Obs(obsToCopy.getPerson(), obsToCopy.getConcept(), obsToCopy.getObsDatetime(),
1✔
191
                        obsToCopy.getLocation());
1✔
192
                
193
                newObs.setObsGroup(obsToCopy.getObsGroup());
1✔
194
                newObs.setAccessionNumber(obsToCopy.getAccessionNumber());
1✔
195
                newObs.setValueCoded(obsToCopy.getValueCoded());
1✔
196
                newObs.setValueDrug(obsToCopy.getValueDrug());
1✔
197
                newObs.setValueGroupId(obsToCopy.getValueGroupId());
1✔
198
                newObs.setValueDatetime(obsToCopy.getValueDatetime());
1✔
199
                newObs.setValueNumeric(obsToCopy.getValueNumeric());
1✔
200
                newObs.setValueModifier(obsToCopy.getValueModifier());
1✔
201
                newObs.setValueText(obsToCopy.getValueText());
1✔
202
                newObs.setComment(obsToCopy.getComment());
1✔
203
                newObs.setEncounter(obsToCopy.getEncounter());
1✔
204
                newObs.setCreator(obsToCopy.getCreator());
1✔
205
                newObs.setDateCreated(obsToCopy.getDateCreated());
1✔
206
                newObs.setVoided(obsToCopy.getVoided());
1✔
207
                newObs.setVoidedBy(obsToCopy.getVoidedBy());
1✔
208
                newObs.setDateVoided(obsToCopy.getDateVoided());
1✔
209
                newObs.setVoidReason(obsToCopy.getVoidReason());
1✔
210
                newObs.setStatus(obsToCopy.getStatus());
1✔
211
                newObs.setInterpretation(obsToCopy.getInterpretation());
1✔
212
                newObs.setOrder(obsToCopy.getOrder());
1✔
213
                
214
                newObs.setValueComplex(obsToCopy.getValueComplex());
1✔
215
                newObs.setComplexData(obsToCopy.getComplexData());
1✔
216
                newObs.setFormField(obsToCopy.getFormFieldNamespace(), obsToCopy.getFormFieldPath());
1✔
217
                
218
                // Copy list of all members, including voided, and put them in respective groups
219
                if (obsToCopy.hasGroupMembers(true)) {
1✔
220
                        for (Obs member : obsToCopy.getGroupMembers(true)) {
1✔
221
                                // if the obs hasn't been saved yet, no need to duplicate it
222
                                if (member.getObsId() == null) {
1✔
223
                                        newObs.addGroupMember(member);
1✔
224
                                } else {
225
                                        Obs newMember = Obs.newInstance(member);
1✔
226
                                        newMember.setPreviousVersion(member);
1✔
227
                                        newObs.addGroupMember(newMember);
1✔
228
                                }
229
                        }
1✔
230
                }
231
                
232
                return newObs;
1✔
233
        }
234
        
235
        // Property accessors
236
        
237
        /**
238
         * @return Returns the comment.
239
         */
240
        public String getComment() {
241
                return comment;
1✔
242
        }
243
        
244
        /**
245
         * @param comment The comment to set.
246
         */
247
        public void setComment(String comment) {
248
                markAsDirty(this.comment, comment);
1✔
249
                this.comment = comment;
1✔
250
        }
1✔
251
        
252
        /**
253
         * @return Returns the concept.
254
         */
255
        public Concept getConcept() {
256
                return concept;
1✔
257
        }
258
        
259
        /**
260
         * @param concept The concept to set.
261
         */
262
        public void setConcept(Concept concept) {
263
                markAsDirty(this.concept, concept);
1✔
264
                this.concept = concept;
1✔
265
        }
1✔
266
        
267
        /**
268
         * Get the concept description that is tied to the concept name that was used when making this
269
         * observation
270
         * 
271
         * @return ConceptDescription the description used
272
         */
273
        public ConceptDescription getConceptDescription() {
274
                // if we don't have a question for this concept,
275
                // then don't bother looking for a description
276
                if (getConcept() == null) {
×
277
                        return null;
×
278
                }
279
                
280
                // ABKTOD: description in which locale?
281
                return concept.getDescription();
×
282
        }
283
        
284
        /**
285
         * @return Returns the encounter.
286
         */
287
        public Encounter getEncounter() {
288
                return encounter;
1✔
289
        }
290
        
291
        /**
292
         * @param encounter The encounter to set.
293
         */
294
        public void setEncounter(Encounter encounter) {
295
                markAsDirty(this.encounter, encounter);
1✔
296
                this.encounter = encounter;
1✔
297
        }
1✔
298
        
299
        /**
300
         * @return Returns the location.
301
         */
302
        public Location getLocation() {
303
                return location;
1✔
304
        }
305
        
306
        /**
307
         * @param location The location to set.
308
         */
309
        public void setLocation(Location location) {
310
                markAsDirty(this.location, location);
1✔
311
                this.location = location;
1✔
312
        }
1✔
313
        
314
        /**
315
         * @return Returns the obsDatetime.
316
         */
317
        public Date getObsDatetime() {
318
                return obsDatetime;
1✔
319
        }
320
        
321
        /**
322
         * @param obsDatetime The obsDatetime to set.
323
         */
324
        public void setObsDatetime(Date obsDatetime) {
325
                markAsDirty(this.obsDatetime, obsDatetime);
1✔
326
                this.obsDatetime = obsDatetime;
1✔
327
        }
1✔
328
        
329
        /**
330
         * An obs grouping occurs when the question (#getConcept()) is a set. (@link
331
         * org.openmrs.Concept#isSet()) If this is non-null, it means the current Obs is in the list
332
         * returned by <code>obsGroup</code>.{@link #getGroupMembers()}
333
         * 
334
         * @return the Obs that is the grouping factor
335
         */
336
        public Obs getObsGroup() {
337
                return obsGroup;
1✔
338
        }
339
        
340
        /**
341
         * This method does NOT add this current obs to the list of obs in obsGroup.getGroupMembers().
342
         * That must be done (and should be done) manually. (I am not doing it here for fear of screwing
343
         * up the normal loading and creation of this object via hibernate/spring)
344
         * 
345
         * @param obsGroup the obsGroup to set
346
         */
347
        public void setObsGroup(Obs obsGroup) {
348
                markAsDirty(this.obsGroup, obsGroup);
1✔
349
                this.obsGroup = obsGroup;
1✔
350
        }
1✔
351
        
352
        /**
353
         * Convenience method that checks for if this obs has 1 or more group members (either voided or
354
         * non-voided) Note this method differs from hasGroupMembers(), as that method excludes voided
355
         * obs; logic is that while a obs that has only voided group members should be seen as
356
         * "having no group members" it still should be considered an "obs grouping"
357
         * <p>
358
         * NOTE: This method could also be called "isObsGroup" for a little less confusion on names.
359
         * However, jstl in a web layer (or any psuedo-getter) access isn't good with both an
360
         * "isObsGroup" method and a "getObsGroup" method. Which one should be returned with a
361
         * simplified jstl call like ${obs.obsGroup} ? With this setup, ${obs.obsGrouping} returns a
362
         * boolean of whether this obs is a parent and has members. ${obs.obsGroup} returns the parent
363
         * object to this obs if this obs is a group member of some other group.
364
         * 
365
         * @return true if this is the parent group of other obs
366
         */
367
        public boolean isObsGrouping() {
368
                return hasGroupMembers(true);
1✔
369
        }
370
        
371
        /**
372
         * A convenience method to check for nullity and length to determine if this obs has group
373
         * members. By default, this ignores voided-objects. To include voided, use
374
         * {@link #hasGroupMembers(boolean)} with value true.
375
         * 
376
         * @return true if this is the parent group of other obs
377
         * <strong>Should</strong> not include voided obs
378
         */
379
        public boolean hasGroupMembers() {
380
                return hasGroupMembers(false);
1✔
381
        }
382
        
383
        /**
384
         * Convenience method that checks for nullity and length to determine if this obs has group
385
         * members. The parameter specifies if this method whether or not voided obs should be
386
         * considered.
387
         * 
388
         * @param includeVoided determines if Voided members should be considered as group members.
389
         * @return true if this is the parent group of other Obs
390
         * <strong>Should</strong> return true if this obs has group members based on parameter
391
         */
392
        public boolean hasGroupMembers(boolean includeVoided) {
393
                // ! symbol used because if it's not empty, we want true
394
                return !org.springframework.util.CollectionUtils.isEmpty(getGroupMembers(includeVoided));
1✔
395
        }
396
        
397
        /**
398
         * Get the non-voided members of the obs group, if this obs is a group. By default this method
399
         * only returns non-voided group members. To get all group members, use
400
         * {@link #getGroupMembers(boolean)} with value true.
401
         * <p>
402
         * If it's not a group (i.e. {@link #getConcept()}.{@link org.openmrs.Concept#getSet()} is not
403
         * true, then this returns null.
404
         * 
405
         * @return a Set&lt;Obs&gt; of the members of this group.
406
         * @see #addGroupMember(Obs)
407
         * @see #hasGroupMembers()
408
         */
409
        public Set<Obs> getGroupMembers() {
410
                //same as just returning groupMembers
411
                return getGroupMembers(false);
1✔
412
        }
413
        
414
        /**
415
         * Get the group members of this obs group, if this obs is a group. This method will either
416
         * return all group members, or only non-voided group members, depending on if the argument is
417
         * set to be true or false respectively.
418
         * 
419
         * @param includeVoided
420
         * @return the set of group members in this obs group
421
         * <strong>Should</strong> Get all group members if passed true, and non-voided if passed false
422
         */
423
        public Set<Obs> getGroupMembers(boolean includeVoided) {
424
                if (includeVoided) {
1✔
425
                        //just return all group members
426
                        return groupMembers;
1✔
427
                }
428
                if (groupMembers == null) {
1✔
429
                        //Empty set so return null
430
                        return null;
1✔
431
                }
432
                Set<Obs> nonVoided = new LinkedHashSet<>(groupMembers);
1✔
433
                nonVoided.removeIf(BaseOpenmrsData::getVoided);
1✔
434
                return nonVoided;
1✔
435
        }
436
        
437
        /**
438
         * Set the members of the obs group, if this obs is a group.
439
         * <p>
440
         * If it's not a group (i.e. {@link #getConcept()}.{@link org.openmrs.Concept#getSet()} is not
441
         * true, then this returns null.
442
         * 
443
         * @param groupMembers the groupedObs to set
444
         * @see #addGroupMember(Obs)
445
         * @see #hasGroupMembers()
446
         * <strong>Should</strong> mark the obs as dirty when the set is changed from null to a non empty one
447
         * <strong>Should</strong> not mark the obs as dirty when the set is changed from null to an empty one
448
         * <strong>Should</strong> mark the obs as dirty when the set is replaced with another with different members
449
         * <strong>Should</strong> not mark the obs as dirty when the set is replaced with another with same members
450
         */
451
        public void setGroupMembers(Set<Obs> groupMembers) {
452
                //Copy over the entire list
453
                this.groupMembers = groupMembers;
1✔
454
                
455
        }
1✔
456
        
457
        /**
458
         * Convenience method to add the given <code>obs</code> to this grouping. Will implicitly make
459
         * this obs an ObsGroup.
460
         * 
461
         * @param member Obs to add to this group
462
         * @see #setGroupMembers(Set)
463
         * @see #getGroupMembers()
464
         * <strong>Should</strong> return true when a new obs is added as a member
465
         * <strong>Should</strong> return false when a duplicate obs is added as a member
466
         */
467
        public void addGroupMember(Obs member) {
468
                if (member == null) {
1✔
469
                        return;
×
470
                }
471
                
472
                if (getGroupMembers() == null) {
1✔
473
                        groupMembers = new HashSet<>();
1✔
474
                }
475
                
476
                // a quick sanity check to make sure someone isn't adding
477
                // itself to the group
478
                if (member.equals(this)) {
1✔
479
                        throw new APIException("Obs.error.groupCannotHaveItselfAsAMentor", new Object[] { this, member });
1✔
480
                }
481
                
482
                member.setObsGroup(this);
1✔
483
                groupMembers.add(member);
1✔
484
        }
1✔
485
        
486
        /**
487
         * Convenience method to remove an Obs from this grouping This also removes the link in the
488
         * given <code>obs</code>object to this obs grouper
489
         * 
490
         * @param member Obs to remove from this group
491
         * @see #setGroupMembers(Set)
492
         * @see #getGroupMembers()
493
         * <strong>Should</strong> return true when an obs is removed
494
         * <strong>Should</strong> return false when a non existent obs is removed
495
         */
496
        public void removeGroupMember(Obs member) {
497
                if (member == null || getGroupMembers() == null) {
1✔
498
                        return;
1✔
499
                }
500
                
501
                if (groupMembers.remove(member)) {
1✔
502
                        member.setObsGroup(null);
1✔
503
                }
504
        }
1✔
505
        
506
        /**
507
         * Convenience method that returns related Obs If the Obs argument is not an ObsGroup: a
508
         * Set&lt;Obs&gt; will be returned containing all of the children of this Obs' parent that are
509
         * not ObsGroups themselves. This will include this Obs by default, unless getObsGroup() returns
510
         * null, in which case an empty set is returned. If the Obs argument is an ObsGroup: a
511
         * Set&lt;Obs&gt; will be returned containing 1. all of this Obs' group members, and 2. all
512
         * ancestor Obs that are not themselves obsGroups.
513
         * 
514
         * @return Set&lt;Obs&gt;
515
         */
516
        public Set<Obs> getRelatedObservations() {
517
                Set<Obs> ret = new HashSet<>();
1✔
518
                if (this.isObsGrouping()) {
1✔
519
                        ret.addAll(this.getGroupMembers());
1✔
520
                        Obs parentObs = this;
1✔
521
                        while (parentObs.getObsGroup() != null) {
1✔
522
                                for (Obs obsSibling : parentObs.getObsGroup().getGroupMembers()) {
1✔
523
                                        if (!obsSibling.isObsGrouping()) {
1✔
524
                                                ret.add(obsSibling);
1✔
525
                                        }
526
                                }
1✔
527
                                parentObs = parentObs.getObsGroup();
1✔
528
                        }
529
                } else if (this.getObsGroup() != null) {
1✔
530
                        for (Obs obsSibling : this.getObsGroup().getGroupMembers()) {
1✔
531
                                if (!obsSibling.isObsGrouping()) {
1✔
532
                                        ret.add(obsSibling);
1✔
533
                                }
534
                        }
1✔
535
                }
536
                return ret;
1✔
537
        }
538
        
539
        /**
540
         * @return Returns the obsId.
541
         */
542
        public Integer getObsId() {
543
                return obsId;
1✔
544
        }
545
        
546
        /**
547
         * @param obsId The obsId to set.
548
         */
549
        public void setObsId(Integer obsId) {
550
                this.obsId = obsId;
1✔
551
        }
1✔
552
        
553
        /**
554
         * @return Returns the order.
555
         */
556
        public Order getOrder() {
557
                return order;
1✔
558
        }
559
        
560
        /**
561
         * @param order The order to set.
562
         */
563
        public void setOrder(Order order) {
564
                markAsDirty(this.order, order);
1✔
565
                this.order = order;
1✔
566
        }
1✔
567
        
568
        /**
569
         * The person id of the person on this object. This should be the same as
570
         * <code>{@link #getPerson()}.getPersonId()</code>. It is duplicated here for speed and
571
         * simplicity reasons
572
         * 
573
         * @return the integer person id of the person this obs is acting on
574
         */
575
        public Integer getPersonId() {
576
                return personId;
1✔
577
        }
578
        
579
        /**
580
         * Set the person id on this obs object. This method is here for convenience, but really the
581
         * {@link #setPerson(Person)} method should be used like
582
         * <code>setPerson(new Person(personId))</code>
583
         * 
584
         * @see #setPerson(Person)
585
         * @param personId
586
         */
587
        protected void setPersonId(Integer personId) {
588
                markAsDirty(this.personId, personId);
1✔
589
                this.personId = personId;
1✔
590
        }
1✔
591
        
592
        /**
593
         * Get the person object that this obs is acting on.
594
         * 
595
         * @see #getPersonId()
596
         * @return the person object
597
         */
598
        public Person getPerson() {
599
                return person;
1✔
600
        }
601
        
602
        /**
603
         * Set the person object to this obs object. This will also set the personId on this obs object
604
         * 
605
         * @see #setPersonId(Integer)
606
         * @param person the Patient/Person object that this obs is acting on
607
         */
608
        public void setPerson(Person person) {
609
                markAsDirty(this.person, person);
1✔
610
                this.person = person;
1✔
611
                if (person != null) {
1✔
612
                        setPersonId(person.getPersonId());
1✔
613
                }
614
        }
1✔
615
        
616
        /**
617
         * Sets the value of this obs to the specified valueBoolean if this obs has a boolean concept.
618
         * 
619
         * @param valueBoolean the boolean value matching the boolean coded concept to set to
620
         */
621
        public void setValueBoolean(Boolean valueBoolean) {
622
                if (getConcept() != null && getConcept().getDatatype() != null && getConcept().getDatatype().isBoolean()) {
1✔
623
                        if (valueBoolean != null) {
1✔
624
                                setValueCoded(valueBoolean ? Context.getConceptService().getTrueConcept() : Context.getConceptService()
1✔
625
                                        .getFalseConcept());
1✔
626
                        } else {
627
                                setValueCoded(null);
×
628
                        }
629
                }
630
        }
1✔
631
        
632
        /**
633
         * Coerces a value to a Boolean representation
634
         * 
635
         * @return Boolean representation of the obs value
636
         * <strong>Should</strong> return true for value_numeric concepts if value is 1
637
         * <strong>Should</strong> return false for value_numeric concepts if value is 0
638
         * <strong>Should</strong> return null for value_numeric concepts if value is neither 1 nor 0
639
         */
640
        public Boolean getValueAsBoolean() {
641
                
642
                if (getValueCoded() != null) {
1✔
643
                        if (getValueCoded().equals(Context.getConceptService().getTrueConcept())) {
×
644
                                return Boolean.TRUE;
×
645
                        } else if (getValueCoded().equals(Context.getConceptService().getFalseConcept())) {
×
646
                                return Boolean.FALSE;
×
647
                        }
648
                } else if (getValueNumeric() != null) {
1✔
649
                        if (getValueNumeric() == 1) {
1✔
650
                                return Boolean.TRUE;
1✔
651
                        } else if (getValueNumeric() == 0) {
1✔
652
                                return Boolean.FALSE;
1✔
653
                        }
654
                }
655
                //returning null is preferred to defaulting to false to support validation of user input is from a form
656
                return null;
1✔
657
        }
658
        
659
        /**
660
         * Returns the boolean value if the concept of this obs is of boolean datatype
661
         * 
662
         * @return true or false if value is set otherwise null
663
         * <strong>Should</strong> return true if value coded answer concept is true concept
664
         * <strong>Should</strong> return false if value coded answer concept is false concept
665
         */
666
        public Boolean getValueBoolean() {
667
                if (getConcept() != null && valueCoded != null && getConcept().getDatatype().isBoolean()) {
1✔
668
                        Concept trueConcept = Context.getConceptService().getTrueConcept();
1✔
669
                        return trueConcept != null && valueCoded.getId().equals(trueConcept.getId());
1✔
670
                }
671
                
672
                return null;
1✔
673
        }
674
        
675
        /**
676
         * @return Returns the valueCoded.
677
         */
678
        public Concept getValueCoded() {
679
                return valueCoded;
1✔
680
        }
681
        
682
        /**
683
         * @param valueCoded The valueCoded to set.
684
         */
685
        public void setValueCoded(Concept valueCoded) {
686
                markAsDirty(this.valueCoded, valueCoded);
1✔
687
                this.valueCoded = valueCoded;
1✔
688
        }
1✔
689
        
690
        /**
691
         * Gets the specific name used for the coded value.
692
         * 
693
         * @return the name of the coded value
694
         */
695
        public ConceptName getValueCodedName() {
696
                return valueCodedName;
1✔
697
        }
698
        
699
        /**
700
         * Sets the specific name used for the coded value.
701
         * 
702
         * @param valueCodedName the name of the coded value
703
         */
704
        public void setValueCodedName(ConceptName valueCodedName) {
705
                markAsDirty(this.valueCodedName, valueCodedName);
1✔
706
                this.valueCodedName = valueCodedName;
1✔
707
        }
1✔
708
        
709
        /**
710
         * @return Returns the valueDrug
711
         */
712
        public Drug getValueDrug() {
713
                return valueDrug;
1✔
714
        }
715
        
716
        /**
717
         * @param valueDrug The valueDrug to set.
718
         */
719
        public void setValueDrug(Drug valueDrug) {
720
                markAsDirty(this.valueDrug, valueDrug);
1✔
721
                this.valueDrug = valueDrug;
1✔
722
        }
1✔
723
        
724
        /**
725
         * @return Returns the valueDatetime.
726
         */
727
        public Date getValueDatetime() {
728
                return valueDatetime;
1✔
729
        }
730
        
731
        /**
732
         * @param valueDatetime The valueDatetime to set.
733
         */
734
        public void setValueDatetime(Date valueDatetime) {
735
                markAsDirty(this.valueDatetime, valueDatetime);
1✔
736
                this.valueDatetime = valueDatetime;
1✔
737
        }
1✔
738
        
739
        /**
740
         * @return the value of this obs as a Date. Note that this uses a java.util.Date, so it includes
741
         *         a time component, that should be ignored.
742
         * @since 1.9
743
         */
744
        public Date getValueDate() {
745
                return valueDatetime;
1✔
746
        }
747
        
748
        /**
749
         * @param valueDate The date value to set.
750
         * @since 1.9
751
         */
752
        public void setValueDate(Date valueDate) {
753
                markAsDirty(this.valueDatetime, valueDate);
1✔
754
                this.valueDatetime = valueDate;
1✔
755
        }
1✔
756
        
757
        /**
758
         * @return the time value of this obs. Note that this uses a java.util.Date, so it includes a
759
         *         date component, that should be ignored.
760
         * @since 1.9
761
         */
762
        public Date getValueTime() {
763
                return valueDatetime;
1✔
764
        }
765
        
766
        /**
767
         * @param valueTime the time value to set
768
         * @since 1.9
769
         */
770
        public void setValueTime(Date valueTime) {
771
                markAsDirty(this.valueDatetime, valueTime);
1✔
772
                this.valueDatetime = valueTime;
1✔
773
        }
1✔
774
        
775
        /**
776
         * @return Returns the valueGroupId.
777
         */
778
        public Integer getValueGroupId() {
779
                return valueGroupId;
1✔
780
        }
781
        
782
        /**
783
         * @param valueGroupId The valueGroupId to set.
784
         */
785
        public void setValueGroupId(Integer valueGroupId) {
786
                markAsDirty(this.valueGroupId, valueGroupId);
1✔
787
                this.valueGroupId = valueGroupId;
1✔
788
        }
1✔
789
        
790
        /**
791
         * @return Returns the valueModifier.
792
         */
793
        public String getValueModifier() {
794
                return valueModifier;
1✔
795
        }
796
        
797
        /**
798
         * @param valueModifier The valueModifier to set.
799
         */
800
        public void setValueModifier(String valueModifier) {
801
                markAsDirty(this.valueModifier, valueModifier);
1✔
802
                this.valueModifier = valueModifier;
1✔
803
        }
1✔
804
        
805
        /**
806
         * @return Returns the valueNumeric.
807
         */
808
        public Double getValueNumeric() {
809
                return valueNumeric;
1✔
810
        }
811
        
812
        /**
813
         * @param valueNumeric The valueNumeric to set.
814
         */
815
        public void setValueNumeric(Double valueNumeric) {
816
                markAsDirty(this.valueNumeric, valueNumeric);
1✔
817
                this.valueNumeric = valueNumeric;
1✔
818
        }
1✔
819
        
820
        /**
821
         * @return Returns the valueText.
822
         */
823
        public String getValueText() {
824
                return valueText;
1✔
825
        }
826
        
827
        /**
828
         * @param valueText The valueText to set.
829
         */
830
        public void setValueText(String valueText) {
831
                markAsDirty(this.valueText, valueText);
1✔
832
                this.valueText = valueText;
1✔
833
        }
1✔
834
        
835
        /**
836
         * @return Returns true if this Obs is complex.
837
         * @since 1.5
838
         * <strong>Should</strong> return true if the concept is complex
839
         */
840
        public boolean isComplex() {
841
                if (getConcept() != null) {
1✔
842
                        return getConcept().isComplex();
1✔
843
                }
844
                
845
                return false;
1✔
846
        }
847
        
848
        /**
849
         * Get the value for the ComplexData. This method is used by the ComplexObsHandler. The
850
         * valueComplex has two parts separated by a bar '|' character: part A) the title; and part B)
851
         * the URI. The title is the readable description of the valueComplex that is returned by
852
         * {@link Obs#getValueAsString(java.util.Locale)}. The URI is the location where the ComplexData is stored.
853
         * 
854
         * @return readable title and URI for the location of the ComplexData binary object.
855
         * @since 1.5
856
         */
857
        public String getValueComplex() {
858
                return this.valueComplex;
1✔
859
        }
860
        
861
        /**
862
         * Set the value for the ComplexData. This method is used by the ComplexObsHandler. The
863
         * valueComplex has two parts separated by a bar '|' character: part A) the title; and part B)
864
         * the URI. The title is the readable description of the valueComplex that is returned by
865
         * {@link Obs#getValueAsString(java.util.Locale)}. The URI is the location where the ComplexData is stored.
866
         * 
867
         * @param valueComplex readable title and URI for the location of the ComplexData binary object.
868
         * @since 1.5
869
         */
870
        public void setValueComplex(String valueComplex) {
871
                markAsDirty(this.valueComplex, valueComplex);
1✔
872
                this.valueComplex = valueComplex;
1✔
873
        }
1✔
874
        
875
        /**
876
         * Set the ComplexData for this Obs. The ComplexData is stored in the file system or elsewhere,
877
         * but is not persisted to the database. <br>
878
         * <br>
879
         * {@link ComplexObsHandler}s that are registered to {@link ConceptComplex}s will persist the
880
         * {@link ComplexData#getData()} object to the correct place for the given concept.
881
         * 
882
         * @param complexData
883
         * @since 1.5
884
         */
885
        public void setComplexData(ComplexData complexData) {
886
                markAsDirty(this.complexData, complexData);
1✔
887
                this.complexData = complexData;
1✔
888
        }
1✔
889
        
890
        /**
891
         * Get the ComplexData. This is retrieved by the {@link ComplexObsHandler} from the file system
892
         * or another location, not from the database. <br>
893
         * <br>
894
         * This will be null unless you call:
895
         * 
896
         * <pre>
897
         * Obs obsWithComplexData =
898
         * Context.getObsService().getComplexObs(obsId, OpenmrsConstants.RAW_VIEW);
899
         * </pre>
900
         *
901
         * @return the complex data for this obs (if its a complex obs)
902
         * @since 1.5
903
         */
904
        public ComplexData getComplexData() {
905
                return this.complexData;
1✔
906
        }
907
        
908
        /**
909
         * @return Returns the accessionNumber.
910
         */
911
        public String getAccessionNumber() {
912
                return accessionNumber;
1✔
913
        }
914
        
915
        /**
916
         * @param accessionNumber The accessionNumber to set.
917
         */
918
        public void setAccessionNumber(String accessionNumber) {
919
                markAsDirty(this.accessionNumber, accessionNumber);
1✔
920
                this.accessionNumber = accessionNumber;
1✔
921
        }
1✔
922
        
923
        /***************************************************************************
924
         * Convenience methods
925
         **************************************************************************/
926
        
927
        /**
928
         * Convenience method for obtaining the observation's value as a string If the Obs is complex,
929
         * returns the title of the complexData denoted by the section of getValueComplex() before the
930
         * first bar '|' character; or returns the entire getValueComplex() if the bar '|' character is
931
         * missing.
932
         *
933
         * @param locale locale for locale-specific depictions of value
934
         * <strong>Should</strong> return first part of valueComplex for complex obs
935
         * <strong>Should</strong> return first part of valueComplex for non null valueComplexes
936
         * <strong>Should</strong> return non precise values for NumericConcepts
937
         * <strong>Should</strong> return date in correct format
938
         * <strong>Should</strong> not return long decimal numbers as scientific notation
939
         * <strong>Should</strong> use commas or decimal places depending on locale
940
         * <strong>Should</strong> not use thousand separator
941
         * <strong>Should</strong> return regular number for size of zero to or greater than ten digits
942
         * <strong>Should</strong> return regular number if decimal places are as high as six
943
         */
944
        public String getValueAsString(Locale locale) {
945
                // formatting for the return of numbers of type double
946
                NumberFormat nf = NumberFormat.getNumberInstance(locale);
1✔
947
                DecimalFormat df = (DecimalFormat) nf;
1✔
948
                // formatting style up to 6 digits
949
                df.applyPattern("#0.0#####");
1✔
950
                //branch on hl7 abbreviations
951
                if (getConcept() != null) {
1✔
952
                        String abbrev = getConcept().getDatatype().getHl7Abbreviation();
1✔
953
                        if ("BIT".equals(abbrev)) {
1✔
UNCOV
954
                                return getValueAsBoolean() == null ? "" : getValueAsBoolean().toString();
×
955
                        } else if ("CWE".equals(abbrev)) {
1✔
956
                                if (getValueCoded() == null) {
1✔
UNCOV
957
                                        return "";
×
958
                                }
959
                                if (getValueDrug() != null) {
1✔
UNCOV
960
                                        return getValueDrug().getFullName(locale);
×
961
                                } else {
962
                                        ConceptName codedName = getValueCodedName();
1✔
963
                                        if (codedName != null) {
1✔
964
                                                return getValueCoded().getName(locale, false).getName();
1✔
965
                                        } else {
UNCOV
966
                                                ConceptName fallbackName = getValueCoded().getName();
×
967
                                                if (fallbackName != null) {
×
968
                                                        return fallbackName.getName();
×
969
                                                } else {
UNCOV
970
                                                        return "";
×
971
                                                }
972
                                                
973
                                        }
974
                                }
975
                        } else if ("NM".equals(abbrev) || "SN".equals(abbrev)) {
1✔
976
                                if (getValueNumeric() == null) {
1✔
UNCOV
977
                                        return "";
×
978
                                } else {
979
                                        if (getConcept() instanceof ConceptNumeric) {
1✔
980
                                                ConceptNumeric cn = (ConceptNumeric) getConcept();
1✔
981
                                                if (!cn.getAllowDecimal()) {
1✔
982
                                                        double d = getValueNumeric();
1✔
983
                                                        int i = (int) d;
1✔
984
                                                        return Integer.toString(i);
1✔
985
                                                } else {
UNCOV
986
                                                        df.format(getValueNumeric());
×
987
                                                }
UNCOV
988
                                        }
×
989
                                }
990
                        } else if ("DT".equals(abbrev)) {
1✔
991
                                DateFormat dateFormat = new SimpleDateFormat(DATE_PATTERN);
1✔
992
                                return (getValueDatetime() == null ? "" : dateFormat.format(getValueDatetime()));
1✔
UNCOV
993
                        } else if ("TM".equals(abbrev)) {
×
994
                                return (getValueDatetime() == null ? "" : Format.format(getValueDatetime(), locale, FORMAT_TYPE.TIME));
×
995
                        } else if ("TS".equals(abbrev)) {
×
996
                                return (getValueDatetime() == null ? "" : Format.format(getValueDatetime(), locale, FORMAT_TYPE.TIMESTAMP));
×
997
                        } else if ("ST".equals(abbrev)) {
×
998
                                return getValueText();
×
999
                        } else if ("ED".equals(abbrev) && getValueComplex() != null) {
×
1000
                                String[] valuesComplex = getValueComplex().split("\\|");
×
1001
                                for (String value : valuesComplex) {
×
1002
                                        if (StringUtils.isNotEmpty(value)) {
×
1003
                                                return value.trim();
×
1004
                                        }
1005
                                }
1006
                        }
1007
                }
1008
                
1009
                // if the datatype is 'unknown', default to just returning what is not null
1010
                if (getValueNumeric() != null) {
1✔
1011
                        return df.format(getValueNumeric());
1✔
UNCOV
1012
                } else if (getValueCoded() != null) {
×
1013
                        if (getValueDrug() != null) {
×
1014
                                return getValueDrug().getFullName(locale);
×
1015
                        } else {
UNCOV
1016
                                ConceptName valudeCodedName = getValueCodedName();
×
1017
                                if (valudeCodedName != null) {
×
1018
                                        return valudeCodedName.getName();
×
1019
                                } else {
UNCOV
1020
                                        return "";
×
1021
                                }
1022
                        }
UNCOV
1023
                } else if (getValueDatetime() != null) {
×
1024
                        return Format.format(getValueDatetime(), locale, FORMAT_TYPE.DATE);
×
1025
                } else if (getValueText() != null) {
×
1026
                        return getValueText();
×
1027
                } else if (hasGroupMembers()) {
×
1028
                        // all of the values are null and we're an obs group...so loop
1029
                        // over the members and just do a getValueAsString on those
1030
                        // this could potentially cause an infinite loop if an obs group
1031
                        // is a member of its own group at some point in the hierarchy
UNCOV
1032
                        StringBuilder sb = new StringBuilder();
×
1033
                        for (Obs groupMember : getGroupMembers()) {
×
1034
                                if (sb.length() > 0) {
×
1035
                                        sb.append(", ");
×
1036
                                }
UNCOV
1037
                                sb.append(groupMember.getValueAsString(locale));
×
1038
                        }
×
1039
                        return sb.toString();
×
1040
                }
1041
                
1042
                // returns the title portion of the valueComplex
1043
                // which is everything before the first bar '|' character.
UNCOV
1044
                if (getValueComplex() != null) {
×
1045
                        String[] valuesComplex = getValueComplex().split("\\|");
×
1046
                        for (String value : valuesComplex) {
×
1047
                                if (StringUtils.isNotEmpty(value)) {
×
1048
                                        return value.trim();
×
1049
                                }
1050
                        }
1051
                }
1052
                
UNCOV
1053
                return "";
×
1054
        }
1055
        
1056
        /**
1057
         * Sets the value for the obs from a string depending on the datatype of the question concept
1058
         *
1059
         * @param s the string to coerce to a boolean
1060
         * <strong>Should</strong> set value as boolean if the datatype of the question concept is boolean
1061
         * <strong>Should</strong> fail if the value of the string is null
1062
         * <strong>Should</strong> fail if the value of the string is empty
1063
         */
1064
        public void setValueAsString(String s) throws ParseException {
1065
                log.debug("getConcept() == {}", getConcept());
1✔
1066
                
1067
                if (getConcept() != null && !StringUtils.isBlank(s)) {
1✔
UNCOV
1068
                        String abbrev = getConcept().getDatatype().getHl7Abbreviation();
×
1069
                        if ("BIT".equals(abbrev)) {
×
1070
                                setValueBoolean(Boolean.valueOf(s));
×
1071
                        } else if ("CWE".equals(abbrev)) {
×
1072
                                throw new RuntimeException("Not Yet Implemented");
×
1073
                        } else if ("NM".equals(abbrev) || "SN".equals(abbrev)) {
×
1074
                                setValueNumeric(Double.valueOf(s));
×
1075
                        } else if ("DT".equals(abbrev)) {
×
1076
                                DateFormat dateFormat = new SimpleDateFormat(DATE_PATTERN);
×
1077
                                setValueDatetime(dateFormat.parse(s));
×
1078
                        } else if ("TM".equals(abbrev)) {
×
1079
                                DateFormat timeFormat = new SimpleDateFormat(TIME_PATTERN);
×
1080
                                setValueDatetime(timeFormat.parse(s));
×
1081
                        } else if ("TS".equals(abbrev)) {
×
1082
                                DateFormat datetimeFormat = new SimpleDateFormat(DATE_TIME_PATTERN);
×
1083
                                setValueDatetime(datetimeFormat.parse(s));
×
1084
                        } else if ("ST".equals(abbrev)) {
×
1085
                                setValueText(s);
×
1086
                        } else {
UNCOV
1087
                                throw new RuntimeException("Don't know how to handle " + abbrev + " for concept: " + getConcept().getName().getName());
×
1088
                        }
1089
                        
UNCOV
1090
                } else {
×
1091
                        throw new RuntimeException("concept is null for " + this);
1✔
1092
                }
UNCOV
1093
        }
×
1094
        
1095
        /**
1096
         * @see java.lang.Object#toString()
1097
         */
1098
        @Override
1099
        public String toString() {
1100
                if (obsId == null) {
1✔
1101
                        return "obs id is null";
1✔
1102
                }
1103
                
1104
                return "Obs #" + obsId.toString();
1✔
1105
        }
1106
        
1107
        /**
1108
         * @since 1.5
1109
         * @see org.openmrs.OpenmrsObject#getId()
1110
         */
1111
        @Override
1112
        public Integer getId() {
1113
                return getObsId();
1✔
1114
                
1115
        }
1116
        
1117
        /**
1118
         * @since 1.5
1119
         * @see org.openmrs.OpenmrsObject#setId(java.lang.Integer)
1120
         */
1121
        @Override
1122
        public void setId(Integer id) {
1123
                setObsId(id);
1✔
1124
                
1125
        }
1✔
1126
        
1127
        /**
1128
         * When ObsService updates an obs, it voids the old version, creates a new Obs with the updates,
1129
         * and adds a reference to the previousVersion in the new Obs. getPreviousVersion returns the
1130
         * last version of this Obs.
1131
         */
1132
        public Obs getPreviousVersion() {
1133
                return previousVersion;
1✔
1134
        }
1135
        
1136
        /**
1137
         * A previousVersion indicates that this Obs replaces an earlier one.
1138
         *
1139
         * @param previousVersion the Obs that this Obs superceeds
1140
         */
1141
        public void setPreviousVersion(Obs previousVersion) {
1142
                markAsDirty(this.previousVersion, previousVersion);
1✔
1143
                this.previousVersion = previousVersion;
1✔
1144
        }
1✔
1145
        
1146
        public Boolean hasPreviousVersion() {
1147
                return getPreviousVersion() != null;
1✔
1148
        }
1149
        
1150
        /**
1151
         * @param creator
1152
         * @see Auditable#setCreator(User)
1153
         */
1154
        @Override
1155
        public void setCreator(User creator) {
1156
                markAsDirty(getCreator(), creator);
1✔
1157
                super.setCreator(creator);
1✔
1158
        }
1✔
1159
        
1160
        /**
1161
         * @param dateCreated
1162
         * @see Auditable#setDateCreated(Date)
1163
         */
1164
        @Override
1165
        public void setDateCreated(Date dateCreated) {
1166
                markAsDirty(getDateCreated(), dateCreated);
1✔
1167
                super.setDateCreated(dateCreated);
1✔
1168
        }
1✔
1169
        
1170
        /**
1171
         * @see org.openmrs.FormRecordable#setFormField(String,String)
1172
         */
1173
        @Override
1174
        public void setFormField(String namespace, String formFieldPath) {
1175
                String oldValue = formNamespaceAndPath;
1✔
1176
                super.setFormField(namespace, formFieldPath);
1✔
1177
                markAsDirty(oldValue, formNamespaceAndPath);
1✔
1178
        }
1✔
1179
        
1180
        /**
1181
         * Returns true if any change has been made to an Obs instance. In general, the only time
1182
         * isDirty() is going to return false is when a new Obs has just been instantiated or loaded
1183
         * from the database and no method that modifies it internally has been invoked.
1184
         *
1185
         * @return true if not changed otherwise false
1186
         * @since 2.0
1187
         * <strong>Should</strong> return false when no change has been made
1188
         * <strong>Should</strong> return true when any immutable field has been changed
1189
         * <strong>Should</strong> return false when only mutable fields are changed
1190
         * <strong>Should</strong> return true when an immutable field is changed from a null to a non null value
1191
         * <strong>Should</strong> return true when an immutable field is changed from a non null to a null value
1192
         */
1193
        public boolean isDirty() {
1194
                return dirty;
1✔
1195
        }
1196
        
1197
        protected void markAsDirty(Object oldValue, Object newValue) {
1198
                //Should we ignore the case for Strings?
1199
                if (!isDirty() && obsId != null && !OpenmrsUtil.nullSafeEquals(oldValue, newValue)) {
1✔
1200
                        dirty = true;
1✔
1201
                }
1202
        }
1✔
1203
        
1204
        /**
1205
         * Similar to FHIR's Observation.interpretation. Supports a subset of FHIR's Observation
1206
         * Interpretation Codes. See https://www.hl7.org/fhir/valueset-observation-interpretation.html
1207
         * 
1208
         * @since 2.1.0
1209
         */
1210
        public Interpretation getInterpretation() {
1211
                return interpretation;
1✔
1212
        }
1213
        
1214
        /**
1215
         * @since 2.1.0
1216
         */
1217
        public void setInterpretation(Interpretation interpretation) {
1218
                markAsDirty(this.interpretation, interpretation);
1✔
1219
                this.interpretation = interpretation;
1✔
1220
        }
1✔
1221
        
1222
        /**
1223
         * Similar to FHIR's Observation.status. Supports a subset of FHIR's ObservationStatus values.
1224
         * At present OpenMRS does not support FHIR's REGISTERED and CANCELLED statuses, because we
1225
         * don't support obs with null values. See:
1226
         * https://www.hl7.org/fhir/valueset-observation-status.html
1227
         * 
1228
         * @since 2.1.0
1229
         */
1230
        public Status getStatus() {
1231
                return status;
1✔
1232
        }
1233
        
1234
        /**
1235
         * @since 2.1.0
1236
         */
1237
        public void setStatus(Status status) {
1238
                markAsDirty(this.status, status);
1✔
1239
                this.status = status;
1✔
1240
        }
1✔
1241
}
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