Merge pull request #42188 from votdev/issue_51408_motd

mgr/dashboard: Add configurable MOTD or wall notification

Reviewed-by: Ernesto Puerta <epuertat@redhat.com>
Reviewed-by: Nizamudeen A <nia@redhat.com>
Reviewed-by: Paul Cuzner <pcuzner@redhat.com>
Reviewed-by: Tatjana Dehler <tdehler@suse.com>
Reviewed-by: sebastian-philipp <NOT@FOUND>
Reviewed-by: Kefu Chai <kchai@redhat.com>
Reviewed-by: Volker Theile <vtheile@suse.com>
This commit is contained in:
Ernesto Puerta 2021-07-19 19:56:50 +02:00 committed by GitHub
commit 64dbe17fdb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 618 additions and 16 deletions

View File

@ -1314,6 +1314,7 @@ and loosely coupled fashion.
.. include:: dashboard_plugins/feature_toggles.inc.rst
.. include:: dashboard_plugins/debug.inc.rst
.. include:: dashboard_plugins/motd.inc.rst
Troubleshooting the Dashboard

View File

@ -0,0 +1,30 @@
.. _dashboard-motd:
Message of the day (MOTD)
^^^^^^^^^^^^^^^^^^^^^^^^^
Displays a configured `message of the day` at the top of the Ceph Dashboard.
The importance of a MOTD can be configured by its severity, which is
`info`, `warning` or `danger`. The MOTD can expire after a given time,
this means it will not be displayed in the UI anymore. Use the following
syntax to specify the expiration time: `Ns|m|h|d|w` for seconds, minutes,
hours, days and weeks. If the MOTD should expire after 2 hours, use `2h`
or `5w` for 5 weeks. Use `0` to configure a MOTD that does not expire.
To configure a MOTD, run the following command::
$ ceph dashboard motd set <severity:info|warning|danger> <expires> <message>
To show the configured MOTD::
$ ceph dashboard motd get
To clear the configured MOTD run::
$ ceph dashboard motd clear
A MOTD with a `info` or `warning` severity can be closed by the user. The
`info` MOTD is not displayed anymore until the local storage cookies are
cleared or a new MOTD with a different severity is displayed. A MOTD with
a 'warning' severity will be displayed again in a new session.

View File

@ -60,3 +60,4 @@ tasks:
- tasks.mgr.dashboard.test_summary
- tasks.mgr.dashboard.test_telemetry
- tasks.mgr.dashboard.test_user
- tasks.mgr.dashboard.test_motd

View File

@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# pylint: disable=too-many-public-methods
from __future__ import absolute_import
import time
from .helper import DashboardTestCase
class MotdTest(DashboardTestCase):
@classmethod
def tearDownClass(cls):
cls._ceph_cmd(['dashboard', 'motd', 'clear'])
super(MotdTest, cls).tearDownClass()
def setUp(self):
super(MotdTest, self).setUp()
self._ceph_cmd(['dashboard', 'motd', 'clear'])
def test_none(self):
data = self._get('/ui-api/motd')
self.assertStatus(200)
self.assertIsNone(data)
def test_set(self):
self._ceph_cmd(['dashboard', 'motd', 'set', 'info', '0', 'foo bar baz'])
data = self._get('/ui-api/motd')
self.assertStatus(200)
self.assertIsInstance(data, dict)
def test_expired(self):
self._ceph_cmd(['dashboard', 'motd', 'set', 'info', '2s', 'foo bar baz'])
time.sleep(5)
data = self._get('/ui-api/motd')
self.assertStatus(200)
self.assertIsNone(data)

View File

@ -1,5 +1,6 @@
<cd-pwd-expiration-notification></cd-pwd-expiration-notification>
<cd-telemetry-notification></cd-telemetry-notification>
<cd-motd></cd-motd>
<cd-notifications-sidebar></cd-notifications-sidebar>
<div class="cd-navbar-top">

View File

@ -9,10 +9,6 @@
background: vv.$secondary;
border-top: 4px solid vv.$primary;
&.isPwdDisplayed {
top: vv.$top-notification-height;
}
.navbar-brand,
.navbar-brand:hover {
color: vv.$gray-200;

View File

@ -1,3 +1,4 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
@ -51,7 +52,7 @@ describe('NavigationComponent', () => {
configureTestBed({
declarations: [NavigationComponent],
imports: [MockModule(NavigationModule)],
imports: [HttpClientTestingModule, MockModule(NavigationModule)],
providers: [
{
provide: AuthStorageService,

View File

@ -1,5 +1,6 @@
import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core';
import * as _ from 'lodash';
import { Subscription } from 'rxjs';
import { Icons } from '~/app/shared/enum/icons.enum';
@ -9,6 +10,7 @@ import {
FeatureTogglesMap$,
FeatureTogglesService
} from '~/app/shared/services/feature-toggles.service';
import { MotdNotificationService } from '~/app/shared/services/motd-notification.service';
import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
import { SummaryService } from '~/app/shared/services/summary.service';
import { TelemetryNotificationService } from '~/app/shared/services/telemetry-notification.service';
@ -43,7 +45,8 @@ export class NavigationComponent implements OnInit, OnDestroy {
private summaryService: SummaryService,
private featureToggles: FeatureTogglesService,
private telemetryNotificationService: TelemetryNotificationService,
public prometheusAlertService: PrometheusAlertService
public prometheusAlertService: PrometheusAlertService,
private motdNotificationService: MotdNotificationService
) {
this.permissions = this.authStorageService.getPermissions();
this.enabledFeature$ = this.featureToggles.get();
@ -70,6 +73,11 @@ export class NavigationComponent implements OnInit, OnDestroy {
this.showTopNotification('telemetryNotificationEnabled', visible);
})
);
this.subs.add(
this.motdNotificationService.motd$.subscribe((motd: any) => {
this.showTopNotification('motdNotificationEnabled', _.isPlainObject(motd));
})
);
}
ngOnDestroy(): void {

View File

@ -0,0 +1,34 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { MotdService } from '~/app/shared/api/motd.service';
import { configureTestBed } from '~/testing/unit-test-helper';
describe('MotdService', () => {
let service: MotdService;
let httpTesting: HttpTestingController;
configureTestBed({
imports: [HttpClientTestingModule],
providers: [MotdService]
});
beforeEach(() => {
service = TestBed.inject(MotdService);
httpTesting = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpTesting.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should get MOTD', () => {
service.get().subscribe();
const req = httpTesting.expectOne('ui-api/motd');
expect(req.request.method).toBe('GET');
});
});

View File

@ -0,0 +1,25 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
export interface Motd {
message: string;
md5: string;
severity: 'info' | 'warning' | 'danger';
// The expiration date in ISO 8601. Does not expire if empty.
expires: string;
}
@Injectable({
providedIn: 'root'
})
export class MotdService {
private url = 'ui-api/motd';
constructor(private http: HttpClient) {}
get(): Observable<Motd | null> {
return this.http.get<Motd | null>(this.url);
}
}

View File

@ -1,5 +1,6 @@
<ngb-alert type="{{ bootstrapClass }}"
[dismissible]="false">
[dismissible]="dismissible"
(close)="onClose()">
<table>
<ng-container *ngIf="size === 'normal'; else slim">
<tr>

View File

@ -1,4 +1,4 @@
import { Component, Input, OnInit } from '@angular/core';
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Icons } from '~/app/shared/enum/icons.enum';
@ -13,7 +13,7 @@ export class AlertPanelComponent implements OnInit {
@Input()
bootstrapClass = '';
@Input()
type: 'warning' | 'error' | 'info' | 'success';
type: 'warning' | 'error' | 'info' | 'success' | 'danger';
@Input()
typeIcon: Icons | string;
@Input()
@ -22,6 +22,15 @@ export class AlertPanelComponent implements OnInit {
showIcon = true;
@Input()
showTitle = true;
@Input()
dismissible = false;
/**
* The event that is triggered when the close button (x) has been
* pressed.
*/
@Output()
dismissed = new EventEmitter();
icons = Icons;
@ -47,6 +56,15 @@ export class AlertPanelComponent implements OnInit {
this.typeIcon = this.typeIcon || Icons.check;
this.bootstrapClass = this.bootstrapClass || 'success';
break;
case 'danger':
this.title = this.title || $localize`Danger`;
this.typeIcon = this.typeIcon || Icons.warning;
this.bootstrapClass = this.bootstrapClass || 'danger';
break;
}
}
onClose(): void {
this.dismissed.emit();
}
}

View File

@ -16,6 +16,7 @@ import { ClickOutsideModule } from 'ng-click-outside';
import { ChartsModule } from 'ng2-charts';
import { SimplebarAngularModule } from 'simplebar-angular';
import { MotdComponent } from '~/app/shared/components/motd/motd.component';
import { DirectivesModule } from '../directives/directives.module';
import { PipesModule } from '../pipes/pipes.module';
import { AlertPanelComponent } from './alert-panel/alert-panel.component';
@ -91,7 +92,8 @@ import { UsageBarComponent } from './usage-bar/usage-bar.component';
DocComponent,
Copy2ClipboardButtonComponent,
DownloadButtonComponent,
FormButtonPanelComponent
FormButtonPanelComponent,
MotdComponent
],
providers: [],
exports: [
@ -117,7 +119,8 @@ import { UsageBarComponent } from './usage-bar/usage-bar.component';
DocComponent,
Copy2ClipboardButtonComponent,
DownloadButtonComponent,
FormButtonPanelComponent
FormButtonPanelComponent,
MotdComponent
]
})
export class ComponentsModule {}

View File

@ -0,0 +1,8 @@
<cd-alert-panel *ngIf="motd"
size="slim"
[showTitle]="false"
[type]="motd.severity"
[dismissible]="motd.severity !== 'danger'"
(dismissed)="onDismissed()">
<span [innerHTML]="motd.message | sanitizeHtml"></span>
</cd-alert-panel>

View File

@ -0,0 +1,26 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardModule } from '~/app/ceph/dashboard/dashboard.module';
import { SharedModule } from '~/app/shared/shared.module';
import { configureTestBed } from '~/testing/unit-test-helper';
import { MotdComponent } from './motd.component';
describe('MotdComponent', () => {
let component: MotdComponent;
let fixture: ComponentFixture<MotdComponent>;
configureTestBed({
imports: [DashboardModule, HttpClientTestingModule, SharedModule]
});
beforeEach(() => {
fixture = TestBed.createComponent(MotdComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,33 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { Motd } from '~/app/shared/api/motd.service';
import { MotdNotificationService } from '~/app/shared/services/motd-notification.service';
@Component({
selector: 'cd-motd',
templateUrl: './motd.component.html',
styleUrls: ['./motd.component.scss']
})
export class MotdComponent implements OnInit, OnDestroy {
motd: Motd | undefined = undefined;
private subscription: Subscription;
constructor(private motdNotificationService: MotdNotificationService) {}
ngOnInit(): void {
this.subscription = this.motdNotificationService.motd$.subscribe((motd: Motd | undefined) => {
this.motd = motd;
});
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
onDismissed(): void {
this.motdNotificationService.hide();
}
}

View File

@ -26,6 +26,7 @@ import { OrdinalPipe } from './ordinal.pipe';
import { RbdConfigurationSourcePipe } from './rbd-configuration-source.pipe';
import { RelativeDatePipe } from './relative-date.pipe';
import { RoundPipe } from './round.pipe';
import { SanitizeHtmlPipe } from './sanitize-html.pipe';
import { TruncatePipe } from './truncate.pipe';
import { UpperFirstPipe } from './upper-first.pipe';
@ -58,7 +59,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
RbdConfigurationSourcePipe,
DurationPipe,
MapPipe,
TruncatePipe
TruncatePipe,
SanitizeHtmlPipe
],
exports: [
ArrayPipe,
@ -87,7 +89,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
RbdConfigurationSourcePipe,
DurationPipe,
MapPipe,
TruncatePipe
TruncatePipe,
SanitizeHtmlPipe
],
providers: [
ArrayPipe,
@ -113,7 +116,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
UpperFirstPipe,
DurationPipe,
MapPipe,
TruncatePipe
TruncatePipe,
SanitizeHtmlPipe
]
})
export class PipesModule {}

View File

@ -0,0 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { DomSanitizer } from '@angular/platform-browser';
import { SanitizeHtmlPipe } from '~/app/shared/pipes/sanitize-html.pipe';
import { configureTestBed } from '~/testing/unit-test-helper';
describe('SanitizeHtmlPipe', () => {
let pipe: SanitizeHtmlPipe;
let domSanitizer: DomSanitizer;
configureTestBed({
providers: [DomSanitizer]
});
beforeEach(() => {
domSanitizer = TestBed.inject(DomSanitizer);
pipe = new SanitizeHtmlPipe(domSanitizer);
});
it('create an instance', () => {
expect(pipe).toBeTruthy();
});
// There is no way to inject a working DomSanitizer in unit tests,
// so it is not possible to test the `transform` method.
});

View File

@ -0,0 +1,13 @@
import { Pipe, PipeTransform, SecurityContext } from '@angular/core';
import { DomSanitizer, SafeValue } from '@angular/platform-browser';
@Pipe({
name: 'sanitizeHtml'
})
export class SanitizeHtmlPipe implements PipeTransform {
constructor(private domSanitizer: DomSanitizer) {}
transform(value: SafeValue | string | null): string | null {
return this.domSanitizer.sanitize(SecurityContext.HTML, value);
}
}

View File

@ -0,0 +1,117 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { Motd } from '~/app/shared/api/motd.service';
import { configureTestBed } from '~/testing/unit-test-helper';
import { MotdNotificationService } from './motd-notification.service';
describe('MotdNotificationService', () => {
let service: MotdNotificationService;
configureTestBed({
providers: [MotdNotificationService],
imports: [HttpClientTestingModule]
});
beforeEach(() => {
service = TestBed.inject(MotdNotificationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should hide [1]', () => {
spyOn(service.motdSource, 'next');
spyOn(service.motdSource, 'getValue').and.returnValue({
severity: 'info',
expires: '',
message: 'foo',
md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
});
service.hide();
expect(localStorage.getItem('dashboard_motd_hidden')).toBe(
'info:acbd18db4cc2f85cedef654fccc4a4d8'
);
expect(sessionStorage.getItem('dashboard_motd_hidden')).toBeNull();
expect(service.motdSource.next).toBeCalledWith(null);
});
it('should hide [2]', () => {
spyOn(service.motdSource, 'getValue').and.returnValue({
severity: 'warning',
expires: '',
message: 'bar',
md5: '37b51d194a7513e45b56f6524f2d51f2'
});
service.hide();
expect(sessionStorage.getItem('dashboard_motd_hidden')).toBe(
'warning:37b51d194a7513e45b56f6524f2d51f2'
);
expect(localStorage.getItem('dashboard_motd_hidden')).toBeNull();
});
it('should process response [1]', () => {
const motd: Motd = {
severity: 'danger',
expires: '',
message: 'foo',
md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
};
spyOn(service.motdSource, 'next');
service.processResponse(motd);
expect(service.motdSource.next).toBeCalledWith(motd);
});
it('should process response [2]', () => {
const motd: Motd = {
severity: 'warning',
expires: '',
message: 'foo',
md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
};
localStorage.setItem('dashboard_motd_hidden', 'info');
service.processResponse(motd);
expect(sessionStorage.getItem('dashboard_motd_hidden')).toBeNull();
expect(localStorage.getItem('dashboard_motd_hidden')).toBeNull();
});
it('should process response [3]', () => {
const motd: Motd = {
severity: 'info',
expires: '',
message: 'foo',
md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
};
spyOn(service.motdSource, 'next');
localStorage.setItem('dashboard_motd_hidden', 'info:acbd18db4cc2f85cedef654fccc4a4d8');
service.processResponse(motd);
expect(service.motdSource.next).not.toBeCalled();
});
it('should process response [4]', () => {
const motd: Motd = {
severity: 'info',
expires: '',
message: 'foo',
md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
};
spyOn(service.motdSource, 'next');
localStorage.setItem('dashboard_motd_hidden', 'info:37b51d194a7513e45b56f6524f2d51f2');
service.processResponse(motd);
expect(service.motdSource.next).toBeCalled();
});
it('should process response [5]', () => {
const motd: Motd = {
severity: 'info',
expires: '',
message: 'foo',
md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
};
spyOn(service.motdSource, 'next');
localStorage.setItem('dashboard_motd_hidden', 'danger:acbd18db4cc2f85cedef654fccc4a4d8');
service.processResponse(motd);
expect(service.motdSource.next).toBeCalled();
});
});

View File

@ -0,0 +1,82 @@
import { Injectable, OnDestroy } from '@angular/core';
import * as _ from 'lodash';
import { BehaviorSubject, EMPTY, Observable, of, Subscription } from 'rxjs';
import { catchError, delay, mergeMap, repeat, tap } from 'rxjs/operators';
import { Motd, MotdService } from '~/app/shared/api/motd.service';
@Injectable({
providedIn: 'root'
})
export class MotdNotificationService implements OnDestroy {
public motd$: Observable<Motd | null>;
public motdSource = new BehaviorSubject<Motd | null>(null);
private subscription: Subscription;
private localStorageKey = 'dashboard_motd_hidden';
constructor(private motdService: MotdService) {
this.motd$ = this.motdSource.asObservable();
// Check every 60 seconds for the latest MOTD configuration.
this.subscription = of(true)
.pipe(
mergeMap(() => this.motdService.get()),
catchError((error) => {
// Do not show an error notification.
if (_.isFunction(error.preventDefault)) {
error.preventDefault();
}
return EMPTY;
}),
tap((motd: Motd | null) => this.processResponse(motd)),
delay(60000),
repeat()
)
.subscribe();
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
hide() {
// Store the severity and MD5 of the current MOTD in local or
// session storage to be able to show it again if the severity
// or message of the latest MOTD has changed.
const motd: Motd = this.motdSource.getValue();
if (motd) {
const value = `${motd.severity}:${motd.md5}`;
switch (motd.severity) {
case 'info':
localStorage.setItem(this.localStorageKey, value);
sessionStorage.removeItem(this.localStorageKey);
break;
case 'warning':
sessionStorage.setItem(this.localStorageKey, value);
localStorage.removeItem(this.localStorageKey);
break;
}
}
this.motdSource.next(null);
}
processResponse(motd: Motd | null) {
const value: string | null =
sessionStorage.getItem(this.localStorageKey) || localStorage.getItem(this.localStorageKey);
let visible: boolean = _.isNull(value);
// Force a hidden MOTD to be shown again if the severity or message
// has been changed.
if (!visible && motd) {
const [severity, md5] = value.split(':');
if (severity !== motd.severity || md5 !== motd.md5) {
visible = true;
sessionStorage.removeItem(this.localStorageKey);
localStorage.removeItem(this.localStorageKey);
}
}
if (visible) {
this.motdSource.next(motd);
}
}
}

View File

@ -48,7 +48,7 @@ if cherrypy is not None:
patch_cherrypy(cherrypy.__version__)
# pylint: disable=wrong-import-position
from .plugins import PLUGIN_MANAGER, debug, feature_toggles # noqa # pylint: disable=unused-import
from .plugins import PLUGIN_MANAGER, debug, feature_toggles, motd # isort:skip # noqa E501 # pylint: disable=unused-import
PLUGIN_MANAGER.hook.init()

View File

@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
import hashlib
import json
from enum import Enum
from typing import Dict, NamedTuple, Optional
from ceph.utils import datetime_now, datetime_to_str, parse_timedelta, str_to_datetime
from mgr_module import CLICommand
from . import PLUGIN_MANAGER as PM
from .plugin import SimplePlugin as SP
class MotdSeverity(Enum):
INFO = 'info'
WARNING = 'warning'
DANGER = 'danger'
class MotdData(NamedTuple):
message: str
md5: str # The MD5 of the message.
severity: MotdSeverity
expires: str # The expiration date in ISO 8601. Does not expire if empty.
@PM.add_plugin # pylint: disable=too-many-ancestors
class Motd(SP):
NAME = 'motd'
OPTIONS = [
SP.Option(
name=NAME,
default='',
type='str',
desc='The message of the day'
)
]
@PM.add_hook
def register_commands(self):
@CLICommand("dashboard {name} get".format(name=self.NAME))
def _get(_):
stdout: str
value: str = self.get_option(self.NAME)
if not value:
stdout = 'No message of the day has been set.'
else:
data = json.loads(value)
if not data['expires']:
data['expires'] = "Never"
stdout = 'Message="{message}", severity="{severity}", ' \
'expires="{expires}"'.format(**data)
return 0, stdout, ''
@CLICommand("dashboard {name} set".format(name=self.NAME))
def _set(_, severity: MotdSeverity, expires: str, message: str):
if expires != '0':
delta = parse_timedelta(expires)
if not delta:
return 1, '', 'Invalid expires format, use "2h", "10d" or "30s"'
expires = datetime_to_str(datetime_now() + delta)
else:
expires = ''
value: str = json.dumps({
'message': message,
'md5': hashlib.md5(message.encode()).hexdigest(),
'severity': severity.value,
'expires': expires
})
self.set_option(self.NAME, value)
return 0, 'Message of the day has been set.', ''
@CLICommand("dashboard {name} clear".format(name=self.NAME))
def _clear(_):
self.set_option(self.NAME, '')
return 0, 'Message of the day has been cleared.', ''
@PM.add_hook
def get_controllers(self):
from ..controllers import RESTController, UiApiController
@UiApiController('/motd')
class MessageOfTheDay(RESTController):
def list(_) -> Optional[Dict]: # pylint: disable=no-self-argument
value: str = self.get_option(self.NAME)
if not value:
return None
data: MotdData = MotdData(**json.loads(value))
# Check if the MOTD has been expired.
if data.expires:
expires = str_to_datetime(data.expires)
if expires < datetime_now():
return None
return data._asdict()
return [MessageOfTheDay]

View File

@ -1,6 +1,8 @@
import datetime
import re
from typing import Optional
def datetime_now() -> datetime.datetime:
"""
@ -66,3 +68,40 @@ def str_to_datetime(string: str) -> datetime.datetime:
raise ValueError("Time data {} does not match one of the formats {}".format(
string, str(fmts)))
def parse_timedelta(delta: str) -> Optional[datetime.timedelta]:
"""
Returns a timedelta object represents a duration, the difference
between two dates or times.
>>> parse_timedelta('foo')
>>> parse_timedelta('2d')
datetime.timedelta(days=2)
>>> parse_timedelta("4w")
datetime.timedelta(days=28)
>>> parse_timedelta("5s")
datetime.timedelta(seconds=5)
>>> parse_timedelta("-5s")
datetime.timedelta(days=-1, seconds=86395)
:param delta: The string to process, e.g. '2h', '10d', '30s'.
:return: The `datetime.timedelta` object or `None` in case of
a parsing error.
"""
parts = re.match(r'(?P<seconds>-?\d+)s|'
r'(?P<minutes>-?\d+)m|'
r'(?P<hours>-?\d+)h|'
r'(?P<days>-?\d+)d|'
r'(?P<weeks>-?\d+)w$',
delta,
re.IGNORECASE)
if not parts:
return None
parts = parts.groupdict()
args = {name: int(param) for name, param in parts.items() if param}
return datetime.timedelta(**args)

View File

@ -7,7 +7,7 @@ deps=
-rrequirements.txt
-c{toxinidir}/../mypy-constrains.txt
commands=
pytest --doctest-modules ceph/deployment/service_spec.py
pytest --doctest-modules ceph/deployment/service_spec.py ceph/utils.py
pytest {posargs}
mypy --config-file=../mypy.ini -p ceph