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

cortex-lab / alyx / 13545595881

26 Feb 2025 01:59PM UTC coverage: 84.933% (+0.9%) from 84.082%
13545595881

push

github

k1o0
Resolves issue #917

Weighing form subjects list has dead subjects last
WaterAdministration form lists dead subjects at end
WaterAdministration form and save methods assert that admin date before
death date

8072 of 9504 relevant lines covered (84.93%)

0.85 hits per line

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

91.28
alyx/actions/models.py
1
from datetime import timedelta
1✔
2
from math import inf
1✔
3

4
import structlog
1✔
5
from one.alf.spec import QC
1✔
6

7
from django.conf import settings
1✔
8
from django.core.validators import MinValueValidator
1✔
9
from django.db import models
1✔
10
from django.utils import timezone
1✔
11

12
from alyx.base import BaseModel, modify_fields, alyx_mail, BaseManager
1✔
13
from misc.models import Lab, LabLocation, LabMember, Note
1✔
14

15

16
logger = structlog.get_logger(__name__)
1✔
17

18

19
def _default_water_type():
1✔
20
    s = WaterType.objects.filter(name='Water')
1✔
21
    if s:
1✔
22
        return s[0].pk
1✔
23
    return None
1✔
24

25

26
@modify_fields(name={
1✔
27
    'blank': False,
28
})
29
class ProcedureType(BaseModel):
1✔
30
    """
31
    A procedure to be performed on a subject.
32
    """
33
    description = models.TextField(blank=True,
1✔
34
                                   help_text="Detailed description "
35
                                   "of the procedure")
36

37
    def __str__(self):
1✔
38
        return self.name
1✔
39

40

41
class Weighing(BaseModel):
1✔
42
    """
43
    A weighing of a subject.
44
    """
45
    user = models.ForeignKey(
1✔
46
        settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL,
47
        help_text="The user who weighed the subject")
48
    subject = models.ForeignKey(
1✔
49
        'subjects.Subject', related_name='weighings',
50
        on_delete=models.CASCADE,
51
        help_text="The subject which was weighed")
52
    date_time = models.DateTimeField(
1✔
53
        null=True, blank=True, default=timezone.now)
54
    weight = models.FloatField(
1✔
55
        validators=[MinValueValidator(limit_value=0)],
56
        help_text="Weight in grams")
57

58
    def expected(self):
1✔
59
        """Expected weighing."""
60
        wc = self.subject.water_control
×
61
        return wc.expected_weight(self.date_time)
×
62

63
    def save(self, *args, **kwargs):
1✔
64
        super(Weighing, self).save(*args, **kwargs)
1✔
65
        from actions.notifications import check_underweight
1✔
66
        check_underweight(self.subject)
1✔
67

68
    def __str__(self):
1✔
69
        return 'Weighing %.2f g for %s' % (self.weight, str(self.subject))
1✔
70

71

72
class WaterType(BaseModel):
1✔
73
    name = models.CharField(max_length=128, unique=True)
1✔
74

75
    def __str__(self):
1✔
76
        return self.name
1✔
77

78

79
class WaterAdministration(BaseModel):
1✔
80
    """
81
    For keeping track of water for subjects not on free water.
82
    """
83
    user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True,
1✔
84
                             on_delete=models.SET_NULL,
85
                             help_text="The user who administered water")
86
    subject = models.ForeignKey('subjects.Subject',
1✔
87
                                on_delete=models.CASCADE,
88
                                related_name='water_administrations',
89
                                help_text="The subject to which water was administered")
90
    date_time = models.DateTimeField(null=True, blank=True,
1✔
91
                                     default=timezone.now)
92
    session = models.ForeignKey('Session', null=True, blank=True, on_delete=models.SET_NULL,
1✔
93
                                related_name='wateradmin_session_related')
94
    water_administered = models.FloatField(validators=[MinValueValidator(limit_value=0)],
1✔
95
                                           null=True, blank=True,
96
                                           help_text="Water administered, in milliliters")
97
    water_type = models.ForeignKey(WaterType, null=True, blank=True, on_delete=models.SET_NULL)
1✔
98
    adlib = models.BooleanField(default=False)
1✔
99

100
    def save(self, *args, **kwargs):
1✔
101
        if not self.water_type:
1✔
102
            wr = WaterRestriction.objects.filter(subject=self.subject).\
1✔
103
                order_by('start_time').last()
104
            if wr:
1✔
105
                self.water_type = wr.water_type
1✔
106
            else:
107
                self.water_type = WaterType.objects.get(pk=_default_water_type())
1✔
108
        if (death_date := self.subject.death_date) and self.date_time.date() > death_date:
1✔
109
            raise ValueError('Water administration date after death date')
1✔
110
        return super(WaterAdministration, self).save(*args, **kwargs)
1✔
111

112
    def expected(self):
1✔
113
        wc = self.subject.water_control
1✔
114
        return wc.expected_water(date=self.date_time)
1✔
115

116
    @property
1✔
117
    def hydrogel(self):
1✔
118
        return 'hydrogel' in self.water_type.name.lower() if self.water_type else None
×
119

120
    def __str__(self):
1✔
121
        if self.water_administered:
1✔
122
            return 'Water %.2fg for %s' % (self.water_administered,
1✔
123
                                           str(self.subject),
124
                                           )
125
        else:
126
            return 'Water adlib for %s' % str(self.subject)
×
127

128

129
class BaseAction(BaseModel):
1✔
130
    """
131
    Base class for an action performed on a subject, such as a recording;
132
    surgery; etc. This should always be accessed through one of its subclasses.
133
    """
134

135
    users = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True,
1✔
136
                                   help_text="The user(s) involved in this action")
137
    subject = models.ForeignKey('subjects.Subject',
1✔
138
                                on_delete=models.CASCADE,
139
                                related_name="%(app_label)s_%(class)ss",
140
                                help_text="The subject on which this action was performed")
141
    location = models.ForeignKey(LabLocation, null=True, blank=True, on_delete=models.SET_NULL,
1✔
142
                                 help_text="The physical location at which the action was "
143
                                 "performed")
144
    lab = models.ForeignKey(Lab, null=True, blank=True, on_delete=models.SET_NULL)
1✔
145
    procedures = models.ManyToManyField('ProcedureType', blank=True,
1✔
146
                                        help_text="The procedure(s) performed")
147
    narrative = models.TextField(blank=True)
1✔
148
    start_time = models.DateTimeField(
1✔
149
        null=True, blank=True, default=timezone.now)
150
    end_time = models.DateTimeField(null=True, blank=True)
1✔
151

152
    def __str__(self):
1✔
153
        return '%s for %s' % (self.__class__.__name__, self.subject)
1✔
154

155
    class Meta:
1✔
156
        abstract = True
1✔
157

158

159
class VirusInjection(BaseAction):
1✔
160
    """
161
    A virus injection.
162
    """
163
    INJECTION_TYPES = (
1✔
164
        ('I', 'Iontophoresis'),
165
        ('P', 'Pressure'),
166
    )
167
    virus_batch = models.CharField(max_length=255, null=True, blank=True)
1✔
168
    injection_volume = models.FloatField(
1✔
169
        null=True, blank=True, help_text="Volume in nanoliters")
170
    rate_of_injection = models.FloatField(
1✔
171
        null=True, blank=True, help_text="TODO: Nanoliters per second / per minute?")
172
    injection_type = models.CharField(max_length=1,
1✔
173
                                      choices=INJECTION_TYPES,
174
                                      default='I', blank=True,
175
                                      help_text="Whether the injection was through "
176
                                      "iontophoresis or pressure")
177

178

179
def _default_surgery_location():
1✔
180
    s = LabLocation.objects.filter(name='Surgery Room')
1✔
181
    if s:
1✔
182
        return s[0].pk
×
183
    return None
1✔
184

185

186
class ChronicRecording(BaseAction):
1✔
187
    pass
1✔
188

189

190
class Surgery(BaseAction):
1✔
191
    """
192
    Surgery performed on a subject.
193
    """
194
    OUTCOME_TYPES = (
1✔
195
        ('a', 'Acute'),
196
        ('r', 'Recovery'),
197
        ('n', 'Non-recovery'),
198
    )
199
    outcome_type = models.CharField(max_length=1,
1✔
200
                                    choices=OUTCOME_TYPES,
201
                                    blank=True,
202
                                    )
203
    location = models.ForeignKey(LabLocation, null=True, blank=True,
1✔
204
                                 on_delete=models.SET_NULL,
205
                                 default=_default_surgery_location,
206
                                 help_text="The physical location at which the surgery was "
207
                                 "performed")
208
    implant_weight = models.FloatField(null=False, blank=False, validators=[MinValueValidator(0)],
1✔
209
                                       help_text="Implant weight in grams")
210

211
    class Meta:
1✔
212
        verbose_name_plural = "surgeries"
1✔
213
        constraints = [
1✔
214
            models.CheckConstraint(
215
                check=models.Q(implant_weight__gte=0), name="implant_weight_gte_0"),
216
        ]
217

218
    def save(self, *args, **kwargs):
1✔
219
        output = super(Surgery, self).save(*args, **kwargs)
1✔
220
        self.subject.set_protocol_number()
1✔
221
        if self.subject.actual_severity == 2:
1✔
222
            # Issue #422
223
            # As soon as a procedure is performed on a subject,
224
            # the severity should be at least moderate (3)
225
            self.subject.actual_severity = 3
1✔
226

227
        if self.outcome_type in ('a', 'n') and self.end_time and not self.subject.death_date:
1✔
228
            self.subject.death_date = self.end_time.date()
1✔
229
        self.subject.save()
1✔
230
        return output
1✔
231

232
    def delete(self, *args, **kwargs):
1✔
233
        output = super(Surgery, self).delete(*args, **kwargs)
1✔
234
        self.subject.set_protocol_number()
1✔
235
        self.subject.save()
1✔
236
        return output
1✔
237

238

239
class Session(BaseAction):
1✔
240
    """
241
    A recording or training session performed on a subject. There is normally only one of
242
    these per day, for example corresponding to a  period of uninterrupted head fixation.
243

244
    Note that you can organize sessions hierarchically by assigning a parent_session.
245
    Sub-sessions could for example corresponding to periods of time in which the same
246
    neurons were recorded, or a particular set of stimuli were presented. Top-level sessions
247
    should have parent_session set to null.
248

249
    If the fields (e.g. users) of a subsession are null, they should inherited from the parent.
250
    """
251
    objects = BaseManager()
1✔
252

253
    parent_session = models.ForeignKey('Session', null=True, blank=True,
1✔
254
                                       on_delete=models.SET_NULL,
255
                                       help_text="Hierarchical parent to this session")
256
    projects = models.ManyToManyField('subjects.Project', blank=True,
1✔
257
                                      verbose_name='Session Projects')
258
    type = models.CharField(max_length=255, null=True, blank=True,
1✔
259
                            help_text="User-defined session type (e.g. Base, Experiment)")
260
    number = models.IntegerField(null=True, blank=True,
1✔
261
                                 help_text="Optional session number for this level")
262
    task_protocol = models.CharField(max_length=1023, blank=True, default='')
1✔
263
    n_trials = models.IntegerField(blank=True, null=True)
1✔
264
    n_correct_trials = models.IntegerField(blank=True, null=True)
1✔
265

266
    QC_CHOICES = [(e.value, e.name) for e in QC]
1✔
267
    qc = models.IntegerField(default=QC.NOT_SET, choices=QC_CHOICES,
1✔
268
                             help_text=' / '.join([str(q[0]) + ': ' + q[1] for q in QC_CHOICES]))
269

270
    extended_qc = models.JSONField(null=True, blank=True,
1✔
271
                                   help_text="Structured data about session QC, "
272
                                             "formatted in a user-defined way")
273

274
    auto_datetime = models.DateTimeField(auto_now=True, blank=True, null=True,
1✔
275
                                         verbose_name='last updated')
276

277
    def save(self, *args, **kwargs):
1✔
278
        # Default project is the subject's projects.
279
        if not self.lab:
1✔
280
            self.lab = self.subject.lab
1✔
281
        return super(Session, self).save(*args, **kwargs)
1✔
282

283
    def __str__(self):
1✔
284
        try:
1✔
285
            string = "%s %s/%s/%s" % (str(self.pk),
1✔
286
                                      self.subject,
287
                                      str(self.start_time)[:10],
288
                                      str(self.number).zfill(3))
289
        except Exception:
×
290
            string = "%s %s" % (str(self.pk), self.subject)
×
291
        return string
1✔
292

293
    @property
1✔
294
    def notes(self):
1✔
295
        return Note.objects.filter(object_id=self.pk)
1✔
296

297

298
class EphysSession(Session):
1✔
299
    """
300
    This proxy class allows to register as a different admin page.
301
    The database is left untouched.
302
    New methods are fine but not new fields.
303
    For what defines an ephys session see actions.admin.EphysSessionAdmin.get_queryset.
304
    """
305
    class Meta:
1✔
306
        proxy = True
1✔
307

308

309
class ImagingSession(Session):
1✔
310
    """
311
    This proxy class allows to register as a different admin page.
312
    The database is left untouched.
313
    New methods are fine but not new fields.
314
    For what defines an ephys session see actions.admin.ImagingSessionAdmin.get_queryset.
315
    """
316
    class Meta:
1✔
317
        proxy = True
1✔
318

319

320
class WaterRestriction(BaseAction):
1✔
321
    """
322
    Water restriction.
323
    """
324

325
    reference_weight = models.FloatField(
1✔
326
        validators=[MinValueValidator(limit_value=0)],
327
        default=0,
328
        help_text="Weight in grams")
329
    water_type = models.ForeignKey(WaterType, null=True, blank=True,
1✔
330
                                   default=_default_water_type, on_delete=models.SET_NULL,
331
                                   help_text='Default Water Type when creating water admin')
332

333
    def is_active(self):
1✔
334
        return self.start_time is not None and self.end_time is None
1✔
335

336
    def delete(self, *args, **kwargs):
1✔
337
        output = super(WaterRestriction, self).delete(*args, **kwargs)
×
338
        self.subject.reinit_water_control()
×
339
        self.subject.set_protocol_number()
×
340
        self.subject.save()
×
341
        return output
×
342

343
    def save(self, *args, **kwargs):
1✔
344
        if not self.reference_weight and self.subject:
1✔
345
            w = self.subject.water_control.last_weighing_before(self.start_time)
1✔
346
            if w:
1✔
347
                self.reference_weight = w[1]
1✔
348
                # makes sure the closest weighing is one week around, break if not
349
                assert abs(w[0] - self.start_time) < timedelta(days=7)
1✔
350

351
        output = super(WaterRestriction, self).save(*args, **kwargs)
1✔
352
        # When creating a water restriction, the subject's protocol number should be changed to 3
353
        # (request by Charu in 03/2022)
354
        if self.subject:
1✔
355
            self.subject.reinit_water_control()
1✔
356
            self.subject.set_protocol_number()
1✔
357
            self.subject.save()
1✔
358
        return output
1✔
359

360

361
class OtherAction(BaseAction):
1✔
362
    """
363
    Another type of action.
364
    """
365
    pass
1✔
366

367

368
# Notifications
369
# ---------------------------------------------------------------------------------
370

371
NOTIFICATION_TYPES = (
1✔
372
    ('responsible_user_change', 'responsible user has changed'),
373
    ('mouse_underweight', 'mouse is underweight'),
374
    ('mouse_water', 'water to give to mouse'),
375
    ('mouse_training', 'check training days'),
376
    ('mouse_not_weighed', 'no weight entered for date')
377
)
378

379

380
# Minimum delay, in seconds, until the same notification can be sent again.
381
NOTIFICATION_MIN_DELAYS = {
1✔
382
    'responsible_user_change': 3600,
383
    'mouse_underweight': 3600,
384
    'mouse_water': 3600
385
}
386

387

388
def delay_since_last_notification(notification_type, title, subject):
1✔
389
    """Return the delay since the last notification corresponding to the given
390
    type, title, subject, in seconds, wheter it was actually sent or not."""
391
    last_notif = Notification.objects.filter(
1✔
392
        notification_type=notification_type,
393
        title=title,
394
        subject=subject).exclude(status='no-send').order_by('send_at').last()
395
    if last_notif:
1✔
396
        date = last_notif.sent_at or last_notif.send_at
1✔
397
        return (timezone.now() - date).total_seconds()
1✔
398
    return inf
1✔
399

400

401
def check_scope(user, subject, scope):
1✔
402
    if subject is None:
1✔
403
        return True
×
404
    # Default scope: mine.
405
    scope = scope or 'mine'
1✔
406
    assert scope in ('none', 'mine', 'lab', 'all')
1✔
407
    if scope == 'mine':
1✔
408
        return subject.responsible_user == user
1✔
409
    elif scope == 'lab':
1✔
410
        return subject.lab.name in (user.lab or ())
1✔
411
    elif scope == 'all':
1✔
412
        return True
1✔
413
    elif scope == 'none':
1✔
414
        return False
1✔
415

416

417
def get_recipients(notification_type, subject=None, users=None):
1✔
418
    """Return the list of users that will receive a notification."""
419
    # Default: initial list of recipients is the subject's responsible user.
420
    if users is None and subject and subject.responsible_user:
1✔
421
        users = [subject.responsible_user]
1✔
422
    if users is None:
1✔
423
        users = []
×
424
    if not subject:
1✔
425
        return users
×
426
    members = LabMember.objects.all()
1✔
427
    rules = NotificationRule.objects.filter(notification_type=notification_type)
1✔
428
    # Dictionary giving the scope of every user in the database.
429
    user_rules = {user: None for user in members}
1✔
430
    user_rules.update({rule.user: rule.subjects_scope for rule in rules})
1✔
431
    # Remove 'none' users from the specified users.
432
    users = [user for user in users if user_rules.get(user, None) != 'none']
1✔
433
    # Return the selected users, and those who opted in in the notification rules.
434
    return users + [member for member in members
1✔
435
                    if check_scope(member, subject, user_rules.get(member, None)) and
436
                    member not in users]
437

438

439
def create_notification(
1✔
440
        notification_type, message, subject=None, users=None, force=None, details=''):
441
    delay = delay_since_last_notification(notification_type, message, subject)
1✔
442
    max_delay = NOTIFICATION_MIN_DELAYS.get(notification_type, 0)
1✔
443
    if not force and delay < max_delay:
1✔
444
        logger.warning(
1✔
445
            "This notification was sent %d s ago (< %d s), skipping.", delay, max_delay)
446
        return
1✔
447
    notif = Notification.objects.create(
1✔
448
        notification_type=notification_type,
449
        title=message,
450
        message=message + '\n\n' + details,
451
        subject=subject)
452
    recipients = get_recipients(notification_type, subject=subject, users=users)
1✔
453
    if recipients:
1✔
454
        notif.users.add(*recipients)
1✔
455
    logger.debug(
1✔
456
        "Create notification '%s' for %s (%s %s)",
457
        message, ', '.join(map(str, notif.users.all())),
458
        notif.status, notif.send_at.strftime('%Y-%m-%d %H:%M'))
459
    notif.send_if_needed()
1✔
460
    return notif
1✔
461

462

463
def send_pending_emails():
1✔
464
    """Send all pending notifications."""
465
    notifications = Notification.objects.filter(status='to-send', send_at__lte=timezone.now())
×
466
    for notification in notifications:
×
467
        notification.send_if_needed()
×
468

469

470
class Notification(BaseModel):
1✔
471
    STATUS_TYPES = (
1✔
472
        ('no-send', 'do not send'),
473
        ('to-send', 'to send'),
474
        ('sent', 'sent'),
475
    )
476

477
    send_at = models.DateTimeField(default=timezone.now)
1✔
478
    sent_at = models.DateTimeField(null=True, blank=True)
1✔
479
    notification_type = models.CharField(max_length=32, choices=NOTIFICATION_TYPES)
1✔
480
    title = models.CharField(max_length=255)
1✔
481
    message = models.TextField(blank=True)
1✔
482
    subject = models.ForeignKey(
1✔
483
        'subjects.Subject', null=True, blank=True, on_delete=models.SET_NULL)
484
    users = models.ManyToManyField(LabMember)
1✔
485
    status = models.CharField(max_length=16, default='to-send', choices=STATUS_TYPES)
1✔
486

487
    def ready_to_send(self):
1✔
488
        return (
1✔
489
            self.status == 'to-send' and
490
            self.send_at <= timezone.now()
491
        )
492

493
    def send_if_needed(self):
1✔
494
        """Send the email if needed and change the status to 'sent'"""
495
        if self.status == 'sent':
1✔
496
            logger.warning("Email already sent at %s.", self.sent_at)
×
497
            return False
×
498
        if not self.ready_to_send():
1✔
499
            logger.warning("Email not ready to send.")
×
500
            return False
×
501
        emails = [user.email for user in self.users.all() if user.email]
1✔
502
        if alyx_mail(emails, self.title, self.message):
1✔
503
            self.status = 'sent'
1✔
504
            self.sent_at = timezone.now()
1✔
505
            self.save()
1✔
506
            return True
1✔
507

508
    def __str__(self):
1✔
509
        return "<Notification '%s' (%s) %s>" % (self.title, self.status, self.send_at)
×
510

511

512
class NotificationRule(BaseModel):
1✔
513
    """For each user and notification type, send the notifications for
514
    a given set of mice (none, all, mine, lab)."""
515

516
    SUBJECT_SCOPES = (
1✔
517
        ('none', 'none'),
518
        ('all', 'all'),
519
        ('mine', 'mine'),
520
        ('lab', 'lab')
521
    )
522

523
    user = models.ForeignKey(LabMember, on_delete=models.CASCADE)
1✔
524
    notification_type = models.CharField(max_length=32, choices=NOTIFICATION_TYPES)
1✔
525
    subjects_scope = models.CharField(max_length=16, choices=SUBJECT_SCOPES)
1✔
526

527
    class Meta:
1✔
528
        unique_together = [('user', 'notification_type')]
1✔
529

530
    def __str__(self):
1✔
531
        return "<Notification rule for %s: %s '%s'>" % (
×
532
            self.user, self.notification_type, self.subjects_scope)
533

534

535
class CullReason(BaseModel):
1✔
536
    description = models.TextField(blank=True, max_length=255)
1✔
537

538
    def __str__(self):
1✔
539
        return self.name
×
540

541

542
class CullMethod(BaseModel):
1✔
543
    description = models.TextField(blank=True, max_length=255)
1✔
544

545
    def __str__(self):
1✔
546
        return self.name
1✔
547

548

549
class Cull(BaseModel):
1✔
550
    """
551
    Culling action
552
    """
553
    user = models.ForeignKey(
1✔
554
        settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL,
555
        help_text="The user who culled the subject")
556
    subject = models.OneToOneField(
1✔
557
        'subjects.Subject', related_name='cull', on_delete=models.CASCADE,
558
        help_text="The culled subject")
559
    date = models.DateField(null=False, blank=False)
1✔
560
    description = models.TextField(blank=True, max_length=255, help_text='Narrative/Details')
1✔
561

562
    cull_reason = models.ForeignKey(
1✔
563
        CullReason, null=True, blank=True, on_delete=models.SET_NULL,
564
        help_text="Reason for culling the subject")
565
    cull_method = models.ForeignKey(
1✔
566
        CullMethod, null=True, blank=True, on_delete=models.SET_NULL,
567
        help_text="How the subject was culled")
568

569
    def __str__(self):
1✔
570
        return "%s Cull" % (self.subject)
×
571

572
    def save(self, *args, **kwargs):
1✔
573
        subject_change = False
1✔
574
        if self.subject.death_date != self.date:
1✔
575
            self.subject.death_date = self.date
1✔
576
            subject_change = True
1✔
577
        if self.subject.cull_method != str(self.cull_method):
1✔
578
            self.subject.cull_method = str(self.cull_method)
1✔
579
            subject_change = True
1✔
580
        if subject_change:
1✔
581
            # End all open water restrictions.
582
            for wr in WaterRestriction.objects.filter(
1✔
583
                    subject=self.subject, start_time__isnull=False, end_time__isnull=True):
584
                wr.end_time = self.date
1✔
585
                logger.debug("Ending water restriction %s.", wr)
1✔
586
                wr.save()
1✔
587
            self.subject.save()
1✔
588
        return super(Cull, self).save(*args, **kwargs)
1✔
589

590
    def delete(self, *args, **kwargs):
1✔
591
        # on deletion of the Cull object, setting the death_date of the related subject to None
592
        sub = self.subject
1✔
593
        output = super(Cull, self).delete(*args, **kwargs)
1✔
594
        sub.cull = None
1✔
595
        sub.death_date = None
1✔
596
        sub.cull_method = ''
1✔
597
        sub.save()
1✔
598
        return output
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