Projet

Général

Profil

0001-statistics-add-time-between-two-statuses-71661.patch

Valentin Deniaud, 07 décembre 2022 13:44

Télécharger (17 ko)

Voir les différences:

Subject: [PATCH] statistics: add time between two statuses (#71661)

 tests/api/test_statistics.py | 261 +++++++++++++++++++++++++++++++++++
 wcs/statistics/views.py      | 128 ++++++++++++++++-
 wcs/urls.py                  |   5 +
 3 files changed, 393 insertions(+), 1 deletion(-)
tests/api/test_statistics.py
4 4
import pytest
5 5

  
6 6
from wcs import fields
7
from wcs.backoffice.management import format_time
7 8
from wcs.blocks import BlockDef
8 9
from wcs.carddef import CardDef
9 10
from wcs.categories import CardDefCategory, Category
......
15 16
from .utils import sign_uri
16 17

  
17 18

  
19
def get_humanized_duration_serie(json_resp):
20
    return [format_time(x) for x in json_resp['data']['series'][0]['data']]
21

  
22

  
18 23
@pytest.fixture
19 24
def pub():
20 25
    pub = create_temporary_pub()
......
246 251
    ]
247 252

  
248 253

  
254
def test_statistics_index_resolution_time(pub):
255
    formdef = FormDef()
256
    formdef.name = 'test 1'
257
    formdef.fields = []
258
    formdef.store()
259
    formdef.data_class().wipe()
260

  
261
    resp = get_app(pub).get(sign_uri('/api/statistics/'))
262
    resolution_time_stat = [x for x in resp.json['data'] if x['id'] == 'resolution_time'][0]
263
    form_filter = [x for x in resolution_time_stat['filters'] if x['id'] == 'form'][0]
264
    assert form_filter['options'] == [{'id': 'test-1', 'label': 'test 1'}]
265

  
266

  
249 267
def test_statistics_forms_count(pub):
250 268
    category_a = Category(name='Category A')
251 269
    category_a.store()
......
762 780

  
763 781
    resp = get_app(pub).get(sign_uri('/api/statistics/cards/count/?card=%s' % 'invalid'), status=400)
764 782
    assert resp.text == 'invalid form'
783

  
784

  
785
def test_statistics_resolution_time(pub, freezer):
786
    workflow = Workflow(name='Workflow One')
787
    new_status = workflow.add_status(name='New status')
788
    middle_status = workflow.add_status(name='Middle status')
789
    workflow.add_status(name='End status')
790
    workflow.add_status(name='End status 2')
791

  
792
    # add jump from new to end
793
    jump = new_status.add_action('jump', id='_jump')
794
    jump.status = '3'
795

  
796
    # add jump form new to middle and from middle to end 2
797
    jump = new_status.add_action('jump', id='_jump')
798
    jump.status = '2'
799
    jump = middle_status.add_action('jump', id='_jump')
800
    jump.status = '4'
801

  
802
    workflow.store()
803

  
804
    formdef = FormDef()
805
    formdef.name = 'test'
806
    formdef.workflow_id = workflow.id
807
    formdef.store()
808

  
809
    freezer.move_to(datetime.date(2021, 1, 1))
810
    formdata_list = []
811
    for i in range(3):
812
        formdata = formdef.data_class()()
813
        formdata.just_created()
814
        formdata_list.append(formdata)
815

  
816
    # one formdata resolved in one day
817
    freezer.move_to(datetime.date(2021, 1, 2))
818
    formdata_list[0].jump_status('3')
819
    formdata_list[0].store()
820

  
821
    # one formdata resolved in two days, passing by middle status
822
    formdata_list[1].jump_status('2')
823
    freezer.move_to(datetime.date(2021, 1, 3))
824
    formdata_list[1].jump_status('4')
825
    formdata_list[1].store()
826

  
827
    # one formdata blocked in middle status for three days
828
    freezer.move_to(datetime.date(2021, 1, 4))
829
    formdata_list[2].jump_status('2')
830
    formdata_list[2].store()
831

  
832
    # by default, count forms between initial status and final statuses
833
    resp = get_app(pub).get(sign_uri('/api/statistics/resolution-time/?form=test'))
834
    assert resp.json['data'] == {
835
        'series': [
836
            {
837
                'data': [86400.0, 172800.0, 129600.0, 129600.0],
838
                'label': 'Time between two statuses',
839
            }
840
        ],
841
        'subfilters': [
842
            {
843
                'id': 'start_status',
844
                'label': 'Start status',
845
                'options': [
846
                    {'id': '1', 'label': 'New status'},
847
                    {'id': '2', 'label': 'Middle status'},
848
                    {'id': '3', 'label': 'End status'},
849
                    {'id': '4', 'label': 'End status 2'},
850
                ],
851
                'required': True,
852
                'default': '1',
853
            },
854
            {
855
                'default': 'done',
856
                'id': 'end_status',
857
                'label': 'End status',
858
                'options': [
859
                    {'id': 'done', 'label': 'Any final status'},
860
                    {'id': '2', 'label': 'Middle status'},
861
                    {'id': '3', 'label': 'End status'},
862
                    {'id': '4', 'label': 'End status 2'},
863
                ],
864
                'required': True,
865
            },
866
        ],
867
        'x_labels': ['Minimum time', 'Maximum time', 'Mean', 'Median'],
868
    }
869

  
870
    assert get_humanized_duration_serie(resp.json) == [
871
        '1 day(s) and 0 hour(s)',
872
        '2 day(s) and 0 hour(s)',
873
        '1 day(s) and 12 hour(s)',
874
        '1 day(s) and 12 hour(s)',
875
    ]
876

  
877
    # specify end status
878
    resp = get_app(pub).get(sign_uri('/api/statistics/resolution-time/?form=test&end_status=3'))
879
    assert get_humanized_duration_serie(resp.json) == [
880
        '1 day(s) and 0 hour(s)',
881
        '1 day(s) and 0 hour(s)',
882
        '1 day(s) and 0 hour(s)',
883
        '1 day(s) and 0 hour(s)',
884
    ]
885

  
886
    # specify start status
887
    resp = get_app(pub).get(sign_uri('/api/statistics/resolution-time/?form=test&start_status=2'))
888
    assert get_humanized_duration_serie(resp.json) == [
889
        '1 day(s) and 0 hour(s)',
890
        '1 day(s) and 0 hour(s)',
891
        '1 day(s) and 0 hour(s)',
892
        '1 day(s) and 0 hour(s)',
893
    ]
894

  
895
    # specify start and end statuses
896
    resp = get_app(pub).get(
897
        sign_uri('/api/statistics/resolution-time/?form=test&start_status=2&end_status=4')
898
    )
899
    assert get_humanized_duration_serie(resp.json) == [
900
        '1 day(s) and 0 hour(s)',
901
        '1 day(s) and 0 hour(s)',
902
        '1 day(s) and 0 hour(s)',
903
        '1 day(s) and 0 hour(s)',
904
    ]
905

  
906
    resp = get_app(pub).get(
907
        sign_uri('/api/statistics/resolution-time/?form=test&start_status=1&end_status=2')
908
    )
909
    assert get_humanized_duration_serie(resp.json) == [
910
        '1 day(s) and 0 hour(s)',
911
        '3 day(s) and 0 hour(s)',
912
        '2 day(s) and 0 hour(s)',
913
        '2 day(s) and 0 hour(s)',
914
    ]
915

  
916
    # unknown statuses
917
    default_resp = get_app(pub).get(sign_uri('/api/statistics/resolution-time/?form=test'))
918
    resp = get_app(pub).get(sign_uri('/api/statistics/resolution-time/?form=test&start_status=42'))
919
    assert resp.json == default_resp.json
920

  
921
    resp = get_app(pub).get(sign_uri('/api/statistics/resolution-time/?form=test&end_status=42'))
922
    assert resp.json == default_resp.json
923

  
924
    # specify start and end statuses which does not match any formdata
925
    resp = get_app(pub).get(
926
        sign_uri('/api/statistics/resolution-time/?form=test&start_status=2&end_status=3')
927
    )
928
    assert resp.json['data']['series'][0]['data'] == []
929

  
930
    # unknown form
931
    resp = get_app(pub).get(sign_uri('/api/statistics/resolution-time/?form=xxx'), status=400)
932

  
933

  
934
def test_statistics_resolution_time_median(pub, freezer):
935
    workflow = Workflow(name='Workflow One')
936
    new_status = workflow.add_status(name='New status')
937
    workflow.add_status(name='End status')
938
    jump = new_status.add_action('jump', id='_jump')
939
    jump.status = '2'
940
    workflow.store()
941

  
942
    formdef = FormDef()
943
    formdef.name = 'test'
944
    formdef.workflow_id = workflow.id
945
    formdef.store()
946

  
947
    for i in range(2, 11):
948
        formdata = formdef.data_class()()
949
        freezer.move_to(datetime.date(2021, 1, 1))
950
        formdata.just_created()
951

  
952
        if i != 10:
953
            # add lots of formdata resolved in a few days
954
            freezer.move_to(datetime.date(2021, 1, i))
955
        else:
956
            # one formdata took 3 months
957
            freezer.move_to(datetime.date(2021, 4, 1))
958

  
959
        formdata.jump_status('2')
960
        formdata.store()
961

  
962
    resp = get_app(pub).get(sign_uri('/api/statistics/resolution-time/?form=test'))
963
    assert get_humanized_duration_serie(resp.json) == [
964
        '1 day(s) and 0 hour(s)',  # min
965
        '89 day(s) and 22 hour(s)',  # max
966
        '13 day(s) and 23 hour(s)',  # mean
967
        '5 day(s) and 0 hour(s)',  # median
968
    ]
969

  
970

  
971
def test_statistics_resolution_time_start_end_filter(pub, freezer):
972
    workflow = Workflow(name='Workflow One')
973
    new_status = workflow.add_status(name='New status')
974
    workflow.add_status(name='End status')
975
    jump = new_status.add_action('jump', id='_jump')
976
    jump.status = '2'
977
    workflow.store()
978

  
979
    formdef = FormDef()
980
    formdef.name = 'test'
981
    formdef.workflow_id = workflow.id
982
    formdef.store()
983

  
984
    # create formdata, the latest being the longest to resolve
985
    for i in range(1, 10):
986
        formdata = formdef.data_class()()
987
        freezer.move_to(datetime.date(2021, 1, i))
988
        formdata.just_created()
989
        freezer.move_to(datetime.date(2021, 1, i * 2))
990
        formdata.jump_status('2')
991
        formdata.store()
992

  
993
    resp = get_app(pub).get(sign_uri('/api/statistics/resolution-time/?form=test'))
994
    assert get_humanized_duration_serie(resp.json) == [
995
        '1 day(s) and 0 hour(s)',  # min
996
        '9 day(s) and 0 hour(s)',  # max
997
        '5 day(s) and 0 hour(s)',  # mean
998
        '5 day(s) and 0 hour(s)',  # median
999
    ]
1000

  
1001
    resp = get_app(pub).get(sign_uri('/api/statistics/resolution-time/?form=test&start=2021-01-05'))
1002
    assert get_humanized_duration_serie(resp.json) == [
1003
        '5 day(s) and 0 hour(s)',  # min
1004
        '9 day(s) and 0 hour(s)',  # max
1005
        '7 day(s) and 0 hour(s)',  # mean
1006
        '7 day(s) and 0 hour(s)',  # median
1007
    ]
1008

  
1009
    resp = get_app(pub).get(sign_uri('/api/statistics/resolution-time/?form=test&end=2021-01-05'))
1010
    assert get_humanized_duration_serie(resp.json) == [
1011
        '1 day(s) and 0 hour(s)',  # min
1012
        '4 day(s) and 0 hour(s)',  # max
1013
        '2 day(s) and 12 hour(s)',  # mean
1014
        '2 day(s) and 12 hour(s)',  # median
1015
    ]
1016

  
1017
    resp = get_app(pub).get(
1018
        sign_uri('/api/statistics/resolution-time/?form=test&start=2021-01-04&end=2021-01-05')
1019
    )
1020
    assert get_humanized_duration_serie(resp.json) == [
1021
        '4 day(s) and 0 hour(s)',  # min
1022
        '4 day(s) and 0 hour(s)',  # max
1023
        '4 day(s) and 0 hour(s)',  # mean
1024
        '4 day(s) and 0 hour(s)',  # median
1025
    ]
wcs/statistics/views.py
15 15
# along with this program; if not, see <http://www.gnu.org/licenses/>.
16 16

  
17 17
import collections
18
import time
18 19

  
19 20
from django.http import HttpResponseBadRequest, HttpResponseForbidden, JsonResponse
20 21
from django.urls import reverse
......
29 30
from wcs.formdata import FormData
30 31
from wcs.formdef import FormDef
31 32
from wcs.qommon import _, misc, pgettext_lazy
32
from wcs.qommon.storage import Contains, Equal, Null, Or, StrictNotEqual
33
from wcs.qommon.storage import Contains, Equal, GreaterOrEqual, Less, Null, Or, StrictNotEqual
33 34

  
34 35

  
35 36
class RestrictedView(View):
......
155 156
                            },
156 157
                        ],
157 158
                    },
159
                    {
160
                        'name': _('Time between two statuses'),
161
                        'url': request.build_absolute_uri(reverse('api-statistics-resolution-time')),
162
                        'id': 'resolution_time',
163
                        'data_type': 'seconds',
164
                        'filters': [
165
                            {
166
                                'id': 'form',
167
                                'label': _('Form'),
168
                                'options': self.get_form_options(FormDef, include_all_option=False),
169
                                'required': True,
170
                                'has_subfilters': True,
171
                            },
172
                        ],
173
                    },
158 174
                ]
159 175
            }
160 176
        )
......
466 482
    formpage_class = CardPage
467 483
    has_global_count_support = False
468 484
    label = _('Cards Count')
485

  
486

  
487
class ResolutionTimeView(RestrictedView):
488
    label = _('Time between two statuses')
489

  
490
    def get(self, request, *args, **kwargs):
491
        formdef_slug = request.GET.get('form', '_nothing')
492
        try:
493
            formdef = FormDef.get_by_urlname(formdef_slug, ignore_migration=True)
494
        except KeyError:
495
            return HttpResponseBadRequest('invalid form')
496

  
497
        results = self.get_statistics(formdef)
498
        return JsonResponse(
499
            {
500
                'data': {
501
                    'x_labels': [x[0] for x in results],
502
                    'series': [{'label': _('Time between two statuses'), 'data': [x[1] for x in results]}],
503
                    'subfilters': self.get_subfilters(formdef),
504
                },
505
                'err': 0,
506
            }
507
        )
508

  
509
    @staticmethod
510
    def get_subfilters(formdef):
511
        status_options = [
512
            {'id': status.id, 'label': status.name} for status in formdef.workflow.possible_status
513
        ]
514

  
515
        return [
516
            {
517
                'id': 'start_status',
518
                'label': _('Start status'),
519
                'options': status_options,
520
                'required': True,
521
                'default': status_options[0]['id'],
522
            },
523
            {
524
                'id': 'end_status',
525
                'label': _('End status'),
526
                'options': [{'id': 'done', 'label': _('Any final status')}] + status_options[1:],
527
                'required': True,
528
                'default': 'done',
529
            },
530
        ]
531

  
532
    def get_statistics(self, formdef):
533
        criterias = [StrictNotEqual('status', 'draft')]
534
        if self.request.GET.get('start'):
535
            criterias.append(GreaterOrEqual('receipt_time', self.request.GET['start']))
536
        if self.request.GET.get('end'):
537
            criterias.append(Less('receipt_time', self.request.GET['end']))
538

  
539
        values = formdef.data_class().select(criterias)
540
        # load all evolutions in a single batch, to avoid as many query as
541
        # there are formdata when computing resolution times statistics.
542
        formdef.data_class().load_all_evolutions(values)
543

  
544
        start_status = self.request.GET.get('start_status', formdef.workflow.possible_status[0].id)
545
        end_status = self.request.GET.get('end_status', 'done')
546

  
547
        try:
548
            start_status = formdef.workflow.get_status(start_status)
549
        except KeyError:
550
            start_status = formdef.workflow.possible_status[0]
551

  
552
        end_statuses = None
553
        if end_status != 'done':
554
            try:
555
                end_statuses = {'wf-%s' % formdef.workflow.get_status(end_status).id}
556
            except KeyError:
557
                pass
558

  
559
        if not end_statuses:
560
            end_statuses = {'wf-%s' % status.id for status in formdef.workflow.get_endpoint_status()}
561

  
562
        res_time_forms = []
563
        for filled in values:
564
            start_time = None
565
            for evo in filled.evolution or []:
566
                if start_status and evo.status == 'wf-%s' % start_status.id:
567
                    start_time = time.mktime(evo.time)
568
                elif evo.status in end_statuses:
569
                    if start_status and not start_time:
570
                        break
571
                    start_time = start_time or time.mktime(filled.receipt_time)
572
                    res_time_forms.append(time.mktime(evo.time) - start_time)
573
                    break
574

  
575
        if not res_time_forms:
576
            return []
577
        res_time_forms.sort()
578

  
579
        sum_times = sum(res_time_forms)
580
        len_times = len(res_time_forms)
581
        mean = sum_times // len_times
582

  
583
        if len_times % 2:
584
            median = res_time_forms[len_times // 2]
585
        else:
586
            midpt = len_times // 2
587
            median = (res_time_forms[midpt - 1] + res_time_forms[midpt]) // 2
588

  
589
        return [
590
            (_('Minimum time'), res_time_forms[0]),
591
            (_('Maximum time'), res_time_forms[-1]),
592
            (_('Mean'), mean),
593
            (_('Median'), median),
594
        ]
wcs/urls.py
61 61
        statistics_views.CardsCountView.as_view(),
62 62
        name='api-statistics-cards-count',
63 63
    ),
64
    path(
65
        'api/statistics/resolution-time/',
66
        statistics_views.ResolutionTimeView.as_view(),
67
        name='api-statistics-resolution-time',
68
    ),
64 69
    # provide django.contrib.auth view names for compatibility with
65 70
    # templates created for classic django applications.
66 71
    path('login/', compat.quixote, name='auth_login'),
67
-