Projet

Général

Profil

0001-backoffice-add-advisory-locking-to-formdata-10075.patch

Frédéric Péters, 29 février 2016 21:24

Télécharger (18,4 ko)

Voir les différences:

Subject: [PATCH] backoffice: add advisory locking to formdata (#10075)

 tests/test_backoffice_pages.py      | 53 +++++++++++++++++++++++++++
 tests/test_sessions.py              | 73 +++++++++++++++++++++++++++++++++++++
 wcs/backoffice/management.py        | 13 +++++--
 wcs/forms/backoffice.py             | 17 +++++++--
 wcs/forms/common.py                 | 41 ++++++++++++++++++++-
 wcs/publisher.py                    |  9 +++++
 wcs/qommon/static/css/dc2/admin.css | 17 +++++++++
 wcs/sessions.py                     | 47 +++++++++++++++++++++++-
 8 files changed, 261 insertions(+), 9 deletions(-)
tests/test_backoffice_pages.py
1715 1715
    resp = resp.form.submit('button_commentable')
1716 1716
    resp = resp.follow()
1717 1717
    assert '<h2 class="foldable folded">Summary</h2>' in resp.body
1718

  
1719
def test_backoffice_advisory_lock(pub):
1720
    pub.session_manager.session_class.wipe()
1721
    create_superuser(pub)
1722
    create_environment(pub)
1723

  
1724
    second_user = pub.user_class(name='foobar')
1725
    second_user.roles = Role.keys()
1726
    second_user.store()
1727
    account = PasswordAccount(id='foobar')
1728
    account.set_password('foobar')
1729
    account.user_id = second_user.id
1730
    account.store()
1731

  
1732
    app = login(get_app(pub))
1733
    resp = app.get('/backoffice/management/form-title/')
1734
    first_link = re.findall('data-link="(\d+/)?"', resp.body)[0]
1735
    assert not 'advisory-lock' in resp.body
1736

  
1737
    app2 = login(get_app(pub), username='foobar', password='foobar')
1738
    resp = app2.get('/backoffice/management/form-title/')
1739
    assert not 'advisory-lock' in resp.body
1740

  
1741
    resp = app.get('/backoffice/management/form-title/' + first_link)
1742
    resp = app2.get('/backoffice/management/form-title/')
1743
    assert 'advisory-lock' in resp.body
1744
    resp = app.get('/backoffice/management/form-title/')
1745
    assert not 'advisory-lock' in resp.body
1746

  
1747
    if pub.is_using_postgresql():
1748
        # check global view
1749
        resp = app2.get('/backoffice/management/listing?limit=100')
1750
        assert 'advisory-lock' in resp.body
1751
        resp = app.get('/backoffice/management/listing?limit=100')
1752
        assert not 'advisory-lock' in resp.body
1753

  
1754
    resp = app.get('/backoffice/management/form-title/' + first_link)
1755
    assert not 'Be warned this form is also being' in resp.body
1756
    assert len(resp.forms) == 1
1757
    resp = app2.get('/backoffice/management/form-title/' + first_link)
1758
    assert 'Be warned this form is also being' in resp.body
1759
    assert len(resp.forms) == 0
1760
    # revisit with first user, no change
1761
    resp = app.get('/backoffice/management/form-title/' + first_link)
1762
    assert not 'Be warned this form is also being' in resp.body
1763
    # back to second
1764
    resp = app2.get('/backoffice/management/form-title/' + first_link)
1765
    assert 'Be warned this form is also being' in resp.body
1766
    resp = resp.click('(unlock actions)')
1767
    resp = resp.follow()
1768
    assert 'Be warned this form is also being' in resp.body
1769
    assert not '(unlock actions)' in resp.body
1770
    assert len(resp.forms) == 1
tests/test_sessions.py
6 6
from quixote import cleanup
7 7

  
8 8
from wcs.qommon.ident.password_accounts import PasswordAccount
9
from wcs.qommon.http_request import HTTPRequest
9 10

  
10 11
from utilities import create_temporary_pub, clean_temporary_pub, get_app, login
11 12

  
......
29 30
    pub.write_cfg()
30 31
    return pub
31 32

  
33
@pytest.fixture
34
def http_request(pub):
35
    req = HTTPRequest(None, {})
36
    req.language = None
37
    pub._set_request(req)
38

  
32 39

  
33 40
@pytest.fixture
34 41
def user(pub):
......
71 78
    session.set_expire(time.time() - 1)
72 79
    session.store()
73 80
    assert 'Logout' not in app.get('/')
81

  
82
def test_sessions_visiting_objects(pub, http_request):
83
    manager = pub.session_manager_class()
84
    # check it starts with nothing
85
    assert len(pub.get_visited_objects()) == 0
86

  
87
    # mark two visits
88
    session1 = manager.session_class(id='session1')
89
    session1.user = 'FOO'
90
    session1.mark_visited_object('formdata-foobar-1')
91
    session1.mark_visited_object('formdata-foobar-2')
92
    session1.store()
93
    assert len(pub.get_visited_objects()) == 2
94
    assert set([x[0] for x in pub.get_object_visitors('formdata-foobar-2')]) == set(['FOO'])
95

  
96
    # mark a visit as being in the past
97
    session1.visiting_objects['formdata-foobar-1'] = time.time() - 35*60
98
    session1.store()
99
    assert len(pub.get_visited_objects()) == 1
100

  
101
    # check older visits are automatically removed
102
    session1 = manager.session_class.get('session1')
103
    assert len(session1.visiting_objects.keys()) == 2
104
    session1.mark_visited_object('formdata-foobar-2')
105
    assert len(session1.visiting_objects.keys()) == 1
106
    session1.store()
107
    assert len(pub.get_visited_objects()) == 1
108
    assert pub.get_visited_objects() == ['formdata-foobar-2']
109

  
110
    # check with a second session
111
    session1.mark_visited_object('formdata-foobar-1')
112
    session1.mark_visited_object('formdata-foobar-2')
113
    session1.store()
114
    assert len(pub.get_visited_objects()) == 2
115

  
116
    # mark a visit as being in the past
117
    session1.visiting_objects['formdata-foobar-1'] = time.time() - 35*60
118
    session1.store()
119
    assert len(pub.get_visited_objects()) == 1
120

  
121
    # check older visits are automatically removed
122
    session1 = manager.session_class.get('session1')
123
    assert len(session1.visiting_objects.keys()) == 2
124
    session1.mark_visited_object('formdata-foobar-2')
125
    assert len(session1.visiting_objects.keys()) == 1
126
    session1.store()
127
    assert len(pub.get_visited_objects()) == 1
128
    assert pub.get_visited_objects() == ['formdata-foobar-2']
129

  
130
    # check with a second session
131
    session2 = manager.session_class(id='session2')
132
    session2.user = 'BAR'
133
    session2.store()
134
    assert len(pub.get_visited_objects()) == 1
135
    session2.mark_visited_object('formdata-foobar-2')
136
    session2.store()
137
    assert len(pub.get_visited_objects()) == 1
138
    session2.mark_visited_object('formdata-foobar-3')
139
    session2.store()
140
    assert len(pub.get_visited_objects()) == 2
141

  
142
    assert pub.get_visited_objects(exclude_user='BAR') == ['formdata-foobar-2']
143

  
144
    # check visitors
145
    assert set([x[0] for x in pub.get_object_visitors('formdata-foobar-2')]) == set(['FOO', 'BAR'])
146
    assert set([x[0] for x in pub.get_object_visitors('formdata-foobar-1')]) == set([])
wcs/backoffice/management.py
687 687
        r = TemplateIO(html=True)
688 688
        r += htmltext('<table id="listing" class="main">')
689 689
        r += htmltext('<thead>')
690
        r += htmltext('<th></th>') # lock
690 691
        if include_submission_channel:
691 692
            r += htmltext('<th data-field-sort-key="submission_channel"><span>%s</span></th>') % _('Channel')
692 693
        r += htmltext('<th data-field-sort-key="formdef_name"><span>%s</span></th>') % _('Form')
......
698 699
        r += htmltext('</thead>')
699 700
        r += htmltext('<tbody>')
700 701
        workflows = {}
702
        visited_objects = get_publisher().get_visited_objects(exclude_user=get_session().user)
701 703
        for formdata in formdatas:
702 704
            if not formdata.formdef.workflow_id in workflows:
703 705
                workflows[formdata.formdef.workflow_id] = formdata.formdef.workflow
704
            r += htmltext('<tr class="status-%s-%s" data-link="%s">' % (
705
                    formdata.formdef.workflow.id,
706
                    formdata.status,
706

  
707
            classes = ['status-%s-%s' % (formdata.formdef.workflow.id, formdata.status)]
708
            object_key = 'formdata-%s-%s' % (formdata.formdef.url_name, formdata.id)
709
            if object_key in visited_objects:
710
                classes.append('advisory-lock')
711
            r += htmltext('<tr class="%s" data-link="%s">' % (
712
                    ' '.join(classes),
707 713
                    formdata.get_url(backoffice=True)))
714
            r += htmltext('<td></td>') # lock
708 715
            if include_submission_channel:
709 716
                r += htmltext('<td>%s</td>') % formdata.get_submission_channel_label()
710 717
            r += htmltext('<td>%s</td>') % formdata.formdef.name
wcs/forms/backoffice.py
68 68
        r += htmltext('<table id="listing" class="sortable tablesorter">')
69 69

  
70 70
        r += htmltext('<colgroup>')
71
        r += htmltext('<col/>') # lock
71 72
        r += htmltext('<col/>')
72 73
        r += htmltext('<col/>')
73 74
        for f in fields:
......
75 76
        r += htmltext('</colgroup>')
76 77

  
77 78
        r += htmltext('<thead><tr>')
79
        r += htmltext('<td></td>') # lock
78 80
        for f in fields:
79 81
            field_sort_key = None
80 82
            if getattr(f, 'fake', False):
......
206 208
        else:
207 209
            url_action = ''
208 210
        root_url = get_publisher().get_root_url()
211
        visited_objects = get_publisher().get_visited_objects(exclude_user=get_session().user)
209 212
        for i, filled in enumerate(items):
213
            classes = ['status-%s-%s' % (filled.formdef.workflow.id, filled.status)]
210 214
            if i%2:
211
                style = 'even'
215
                classes.append('even')
212 216
            else:
213
                style = 'odd'
217
                classes.append('odd')
218

  
219
            object_key = 'formdata-%s-%s' % (filled.formdef.url_name, filled.id)
220
            if object_key in visited_objects:
221
                classes.append('advisory-lock')
222

  
214 223
            link = str(filled.id) + '/'
215 224
            data = ' data-link="%s"' % link
216 225
            if filled.anonymised:
217 226
                data += ' data-anonymised="true"'
218
            r += htmltext('<tr class="status-%s-%s %s"%s>' % (filled.formdef.workflow.id,
219
                    filled.status, style, data))
227
            r += htmltext('<tr class="%s"%s>' % (' '.join(classes), data))
228
            r += htmltext('<td></td>') # lock
220 229
            for i, f in enumerate(fields):
221 230
                if f.type == 'id':
222 231
                    r += htmltext('<td class="cell-id"><a href="%s%s">%s</a></td>') % (link, url_action,
wcs/forms/common.py
466 466

  
467 467

  
468 468
    def status(self):
469
        if get_request().get_query() == 'unlock':
470
            # mark user as active visitor of the object, then redirect to self,
471
            # the unlocked form will appear.
472
            object_key = 'formdata-%s-%s' % (self.formdef.url_name, self.filled.id)
473
            get_session().mark_visited_object(object_key)
474
            return redirect('./#lock-notice')
475

  
469 476
        user = self.check_receiver()
470 477
        form = None
471 478

  
......
522 529
        r += self.history()
523 530

  
524 531
        if form:
525
            r += form.render()
532
            object_key = 'formdata-%s-%s' % (self.formdef.url_name, self.filled.id)
533
            all_visitors = get_publisher().get_object_visitors(object_key)
534
            visitors = [x for x in all_visitors if x[0] != get_session().user]
535
            me_in_visitors = bool(get_session().user in [x[0] for x in all_visitors])
536
            if visitors:
537
                current_timestamp = time.time()
538
                visitor_users = []
539
                for visitor_id, visitor_timestamp in visitors:
540
                    try:
541
                        visitor_name = get_publisher().user_class.get(visitor_id).display_name
542
                    except KeyError:
543
                        continue
544
                    minutes_ago = int((current_timestamp - visitor_timestamp) / 60)
545
                    if minutes_ago < 1:
546
                        time_ago = _('less than a minute ago')
547
                    else:
548
                        time_ago = _('less than %s minutes ago') % (minutes_ago + 1)
549
                    visitor_users.append('%s (%s)' % (visitor_name, time_ago))
550
                if visitor_users:
551
                    r += htmltext('<div id="lock-notice" class="infonotice"><p>')
552
                    r += _('Be warned this form is also being looked at by: '
553
                           '%s.') % ', '.join(visitor_users)
554
                    r += ' '
555
                    r += htmltext('</p>')
556
                    if not me_in_visitors:
557
                        r += htmltext('<p class="action"><a href="?unlock">%s</a></p>'
558
                                ) % _('(unlock actions)')
559
                    r += htmltext('</div>')
560
            if not visitors or me_in_visitors:
561
                r += form.render()
562
                get_session().mark_visited_object(object_key)
526 563

  
527 564
        if (self.filled.get_status() and self.filled.get_status().backoffice_info_text) or (
528 565
                form and any((getattr(button, 'backoffice_info_text', None)
......
654 691
            f.edited_data = self.filled
655 692
            f.edit_action_id = action_id
656 693
            f.action_url = 'wfedit-%s' % action_id
694
            get_session().mark_visited_object('formdata-%s-%s' % (
695
                self.formdef.url_name, self.filled.id))
657 696
            get_response().breadcrumb = get_response().breadcrumb[:-1]
658 697
            get_response().breadcrumb.append((f.action_url, _('Edit')))
659 698
            return f._q_index()
wcs/publisher.py
214 214
            request.response.iframe_mode = True
215 215
        return QommonPublisher.try_publish(self, request)
216 216

  
217
    def get_object_visitors(self, object_key):
218
        session_manager = self.session_manager_class()
219
        return session_manager.session_class.get_object_visitors(object_key)
220

  
221
    def get_visited_objects(self, exclude_user=None):
222
        session_manager = self.session_manager_class()
223
        return session_manager.session_class.get_visited_objects(
224
                exclude_user=exclude_user)
225

  
217 226
    def initialize_sql(self):
218 227
        import sql
219 228
        sql.get_connection(new=True)
wcs/qommon/static/css/dc2/admin.css
339 339
	margin-bottom: 1em;
340 340
}
341 341

  
342
#listing thead td {
343
	background: white;
344
}
345

  
342 346
ul.biglist, table#listing {
343 347
	-webkit-transition: opacity 500ms ease-out;
344 348
	-moz-transition: opacity 500ms ease-out;
......
412 416
	margin: 0 1ex;
413 417
}
414 418

  
419
table.main tr.advisory-lock {
420
	opacity: 0.5;
421
}
422

  
423
tr.advisory-lock td:first-child::before {
424
	font-family: FontAwesome;
425
	content: "\f023"; /* lock */
426
}
427

  
415 428
div.bo-block ul.biglist li.user-is-admin strong a {
416 429
	border-left: 5px solid #0099ff;
417 430
}
......
1083 1096
	background: #FFFFA0;
1084 1097
}
1085 1098

  
1099
.infonotice p.action {
1100
	text-align: right;
1101
}
1102

  
1086 1103
div.WidgetDict div.content div.StringWidget {
1087 1104
	width: 25%;
1088 1105
}
wcs/sessions.py
15 15
# along with this program; if not, see <http://www.gnu.org/licenses/>.
16 16

  
17 17
import random
18
import time
18 19

  
19 20
import qommon.sessions
20 21
from qommon.sessions import Session
......
24 25
    anonymous_key = None
25 26
    magictokens = None
26 27
    anonymous_formdata_keys = None
28
    visiting_objects = None
27 29

  
28 30
    def has_info(self):
29
        return self.anonymous_formdata_keys or self.anonymous_key or self.magictokens or Session.has_info(self)
31
        return (self.anonymous_formdata_keys or self.anonymous_key or
32
                self.magictokens or self.visiting_objects or Session.has_info(self))
30 33
    is_dirty = has_info
31 34

  
32 35
    def get_anonymous_key(self, generate = False):
......
63 66
        formdata_key = '%s-%s' % (formdata.formdef.id, formdata.id)
64 67
        return formdata_key in self.anonymous_formdata_keys
65 68

  
69
    def mark_visited_object(self, key):
70
        if not self.visiting_objects:
71
            self.visiting_objects = {}
72
        # first clean older objects
73
        current_timestamp = time.time()
74
        for object_key, object_timestamp in self.visiting_objects.items():
75
            if object_timestamp < (current_timestamp - 30*60):
76
                del self.visiting_objects[object_key]
77
        self.visiting_objects[key] = current_timestamp
78

  
79
    @classmethod
80
    def get_visited_objects(cls, exclude_user=None):
81
        # return the list of visited objects
82
        current_timestamp = time.time()
83
        visited_objects = {}
84
        for session in cls.select():
85
            if session.user and session.user == exclude_user:
86
                continue
87
            visiting_objects = getattr(session, 'visiting_objects', None)
88
            if not visiting_objects:
89
                continue
90
            for object_key, object_timestamp in visiting_objects.items():
91
                if object_timestamp > (current_timestamp - 30*60):
92
                    visited_objects[object_key] = True
93
        return visited_objects.keys()
94

  
95
    @classmethod
96
    def get_object_visitors(cls, object_key):
97
        '''return tuples of (user_id, last_visit_timestamp)'''
98
        current_timestamp = time.time()
99
        visitors = {}
100
        for session in cls.select():
101
            visiting_objects = getattr(session, 'visiting_objects', None)
102
            if not visiting_objects:
103
                continue
104
            object_timestamp = visiting_objects.get(object_key)
105
            if not object_timestamp:
106
                continue
107
            if object_timestamp > (current_timestamp - 30*60):
108
                visitors[session.user] = max(object_timestamp, visitors.get(session.user, 0))
109
        return visitors.items()
110

  
66 111
qommon.sessions.BasicSession = BasicSession
67 112
StorageSessionManager = qommon.sessions.StorageSessionManager
68
-