[closes #1399287] improve the ticket validation process

Ticket workflow was redefined. We defined a new state in the ticket workflow. It's called not validated. A transition was created between the state validation pending and this new one.

After the transition, a new ticket is automatically created with a link towards the original ticket.

Ticket's primary view was updated accordingly. * * * i18nupdate

authorSylvain Thénault <sylvain.thenault@logilab.fr>
changesetbe931dbcd9a7
branchstable
phasepublic
hiddenno
parent revision#f44bfdc62880 must use fire_transition_if_possible (introduced in cw3.12), else this will let the transaction in an uncommitable state
child revision#5a26378194ff 1.7
files modified by this revision
__pkginfo__.py
data/cubes.forge.css
entities.py
hooks.py
i18n/en.po
i18n/es.po
i18n/fr.po
migration/1.7.0_Any.py
migration/postcreate.py
schema.py
test/unittest_forge.py
test/unittest_security.py
views/__init__.py
views/boxes.py
views/forms.py
views/ticket.py
# HG changeset patch
# User Sylvain Thénault <sylvain.thenault@logilab.fr>
# Date 1308658937 -7200
# Tue Jun 21 14:22:17 2011 +0200
# Branch stable
# Node ID be931dbcd9a73c1e7c874f69b97afbae634b21dd
# Parent f44bfdc62880d826b2962414e1e92443dbf75730
[closes #1399287] improve the ticket validation process

Ticket workflow was redefined. We defined a new state in the ticket
workflow. It's called ``not validated``. A transition was created between
the state ``validation pending`` and this new one.

After the transition, a new ticket is automatically created with a
link towards the original ticket.

Ticket's primary view was updated accordingly.
* * *
i18nupdate

diff --git a/__pkginfo__.py b/__pkginfo__.py
@@ -1,11 +1,11 @@
1  # pylint: disable-msg=W0622
2  """cubicweb-forge application packaging information"""
3  modname = 'forge'
4  distname = 'cubicweb-forge'
5 
6 -numversion = (1, 6, 2)
7 +numversion = (1, 7, 0)
8  version = '.'.join(str(num) for num in numversion)
9 
10  license = 'LGPL'
11  author = 'LOGILAB S.A. (Paris, FRANCE)'
12  author_email = 'contact@logilab.fr'
diff --git a/data/cubes.forge.css b/data/cubes.forge.css
@@ -1,21 +1,36 @@
13  .quickLinks {
14 - padding-top: 2em;
15 - padding-left: 1em;
16 - margin-right: 1em;
17 +    padding-top: 2em;
18 +    padding-left: 1em;
19 +    margin-right: 1em;
20  }
21  .quickLinks a {
22 - color: #808080;
23 - text-decoration: none;
24 +    color: #808080;
25 +    text-decoration: none;
26  }
27  .quickLinks table {
28 - margin-top: 1em;
29 +    margin-top: 1em;
30  }
31 
32  a.seemore{
33 - color: #666666;
34 - text-decoration: none;
35 +    color: #666666;
36 +    text-decoration: none;
37  }
38 
39  a.highlighted{
40 - color: #000;
41 +    color: #000;
42  }
43 +
44 +#followup{
45 +    padding: 2px;
46 +    text-align: center;
47 +    margin-bottom: 5px;
48 +}
49 +
50 +dt.current{
51 +    font-weight: bold;
52 +}
53 +
54 +dt.other{
55 +    margin-top:2px;
56 +    margin-bottom:2px;
57 +}
diff --git a/entities.py b/entities.py
@@ -180,10 +180,11 @@
58 
59  class Ticket(ticket.Ticket):
60 
61      fetch_attrs = ('title', 'type', 'priority', 'load', 'load_left', 'in_state')
62      noload_cost = 10
63 +    skip_copy_for = ticket.Ticket.skip_copy_for + ('done_in',)
64 
65      # ticket'specific logic ###################################################
66 
67      OPEN_STATES = frozenset(('open', 'waiting feedback', 'in-progress'))
68 
diff --git a/hooks.py b/hooks.py
@@ -52,37 +52,10 @@
69                      iwf.fire_transition_if_possible('ask validation',
70                                                      comment=msg,
71                                                      commentformat=u'text/plain')
72 
73 
74 -class AdjustPendingTicketState(Operation):
75 -    def precommit_event(self):
76 -        iwf = self.ticket.cw_adapt_to('IWorkflowable')
77 -        if (iwf.state == 'validation pending' and
78 -            (not self.ticket.done_in
79 -             or self.ticket.done_in[0].cw_adapt_to('IWorkflowable').state != 'published')):
80 -            msg = self.session._('moved away from published version')
81 -            iwf.change_state('open', comment=msg, commentformat=u'text/plain')
82 -
83 -
84 -class ChangeTicketStateOnDoneInChange(Hook):
85 -    """automatically add user who adds a comment to the nosy list"""
86 -    __regid__ = 'change_ticket_state_on_done'
87 -    events = ('after_delete_relation',)
88 -    __select__ = Hook.__select__ & hook.match_rtype('done_in',)
89 -
90 -    def __call__(self):
91 -        ticket = self._cw.entity_from_eid(self.eidfrom)
92 -        version = self._cw.entity_from_eid(self.eidto)
93 -        # only change tickets in the validation pending state, not done
94 -        # (since we want this behaviour to occurs for tickets moved
95 -        # from a published version to an unpublished one, usually because
96 -        # of non-validation
97 -        if ticket.cw_adapt_to('IWorkflowable').state == 'validation pending':
98 -            AdjustPendingTicketState(self._cw, ticket=ticket)
99 -
100 -
101  class ResetLoadLeftOnTicketStatusChange(Hook):
102      __regid__ = 'reset_load_left_on_ticket'
103      events = ('after_add_entity',)
104      __select__ = Hook.__select__ & is_instance('TrInfo',)
105 
diff --git a/i18n/en.po b/i18n/en.po
@@ -15,10 +15,13 @@
106  "Content-Type: text/plain; charset=UTF-8\n"
107  "Content-Transfer-Encoding: 8bit\n"
108  "Generated-By: pygettext.py 1.5\n"
109  "Plural-Forms: nplurals=2; plural=(n > 1);\n"
110 
111 +msgid "A new ticket following-up the former must be created"
112 +msgstr ""
113 +
114  msgid "All latest releases..."
115  msgstr ""
116 
117  msgid "All new projects..."
118  msgstr ""
@@ -115,20 +118,20 @@
119 
120  msgid "active development"
121  msgstr ""
122 
123  msgid "add ExtProject filed_under Folder object"
124 -msgstr ""
125 +msgstr "external project"
126 
127  msgid "add Project documented_by Card subject"
128  msgstr "documentation card"
129 
130  msgid "add Project documented_by File subject"
131  msgstr "documentation file"
132 
133  msgid "add Project screenshot File subject"
134 -msgstr ""
135 +msgstr "screenshot"
136 
137  msgid "add Project subproject_of Project object"
138  msgstr "subproject"
139 
140  msgid "add Ticket attachment File subject"
@@ -153,13 +156,10 @@
141  msgstr "attachment"
142 
143  msgid "burn down chart"
144  msgstr ""
145 
146 -msgid "codebrowser_tab"
147 -msgstr "code browser"
148 -
149  msgctxt "Card"
150  msgid "comments_object"
151  msgstr "commented by"
152 
153  msgctxt "Email"
@@ -194,15 +194,21 @@
154  msgid "creating File (Project %(linkto)s) documented_by File"
155  msgstr "adding documentation file for project %(linkto)s"
156 
157  #, python-format
158  msgid "creating File (Project %(linkto)s) screenshot File"
159 -msgstr ""
160 +msgstr "screenshot for %(linkto)s"
161 
162  msgid "creating File (Ticket %(linkto)s attachment File)"
163  msgstr "adding attachement for ticket %(linkto)s"
164 
165 +msgid "ctxcomponents_forge.followup_history"
166 +msgstr "ticket's tracability chain"
167 +
168 +msgid "ctxcomponents_forge.followup_history_description"
169 +msgstr ""
170 +
171  msgid "ctxcomponents_tickettests"
172  msgstr "tests section (ticket)"
173 
174  msgid "ctxcomponents_tickettests_description"
175  msgstr "block of links to test cases of a ticket"
@@ -274,11 +280,11 @@
176 
177  msgid "expected date"
178  msgstr ""
179 
180  msgid "facets_forge.attachment-facet"
181 -msgstr ""
182 +msgstr "has attachment"
183 
184  msgid "facets_forge.attachment-facet_description"
185  msgstr ""
186 
187  msgctxt "Card"
@@ -299,10 +305,27 @@
188 
189  msgid ""
190  "files related to this ticket (screenshot, file needed to reproduce a bug...)"
191  msgstr ""
192 
193 +msgid "follow-up ticket"
194 +msgstr ""
195 +
196 +msgid "follow_up"
197 +msgstr "follow-up"
198 +
199 +msgctxt "Ticket"
200 +msgid "follow_up"
201 +msgstr "follow-up"
202 +
203 +msgid "follow_up_object"
204 +msgstr "followed-up by"
205 +
206 +msgctxt "Ticket"
207 +msgid "follow_up_object"
208 +msgstr "followed-up by"
209 +
210  msgid "forge ticket workflow"
211  msgstr ""
212 
213  msgid "forge.version.burndown_tab"
214  msgstr "burn down chart"
@@ -399,13 +422,10 @@
215  msgstr ""
216 
217  msgid "moved"
218  msgstr ""
219 
220 -msgid "moved away from published version"
221 -msgstr ""
222 -
223  msgid "n/a"
224  msgstr ""
225 
226  msgctxt "ExtProject"
227  msgid "name"
@@ -416,10 +436,13 @@
228  msgstr "name"
229 
230  msgid "no more maintained"
231  msgstr ""
232 
233 +msgid "not validated"
234 +msgstr ""
235 +
236  # bug/story states
237  msgid "open"
238  msgstr ""
239 
240  msgid "planned_delivery"
@@ -492,10 +515,13 @@
241 
242  msgctxt "Project"
243  msgid "recommends_object"
244  msgstr "recommended by"
245 
246 +msgid "refuse validation"
247 +msgstr ""
248 +
249  msgid "reject"
250  msgstr ""
251 
252  msgid "rejected"
253  msgstr ""
@@ -525,11 +551,11 @@
254  msgid "screenshot_object"
255  msgstr "screenshot of"
256 
257  msgctxt "File"
258  msgid "screenshot_object"
259 -msgstr ""
260 +msgstr "screenshot of"
261 
262  msgid "screenshots_tab"
263  msgstr "screenshots"
264 
265  msgid "see them all"
diff --git a/i18n/es.po b/i18n/es.po
@@ -9,10 +9,13 @@
266  "Content-Type: text/plain; charset=UTF-8\n"
267  "Content-Transfer-Encoding: 8bit\n"
268  "Generated-By: cubicweb-devtools\n"
269  "Plural-Forms: nplurals=2; plural=(n > 1);\n"
270 
271 +msgid "A new ticket following-up the former must be created"
272 +msgstr ""
273 +
274  msgid "All latest releases..."
275  msgstr "Ver todos los desarrollos..."
276 
277  msgid "All new projects..."
278  msgstr "Ver todos los nuevos proyectos..."
@@ -149,13 +152,10 @@
279  msgstr "Anexo"
280 
281  msgid "burn down chart"
282  msgstr "Gráfica de Avances"
283 
284 -msgid "codebrowser_tab"
285 -msgstr "Código Fuente"
286 -
287  msgctxt "Card"
288  msgid "comments_object"
289  msgstr "Comentado por"
290 
291  msgctxt "Email"
@@ -195,10 +195,16 @@
292  msgstr ""
293 
294  msgid "creating File (Ticket %(linkto)s attachment File)"
295  msgstr "Agregar un documento para el Ticket %(linkto)s"
296 
297 +msgid "ctxcomponents_forge.followup_history"
298 +msgstr ""
299 +
300 +msgid "ctxcomponents_forge.followup_history_description"
301 +msgstr ""
302 +
303  msgid "ctxcomponents_tickettests"
304  msgstr "Sección de Prueba (Tickets)"
305 
306  msgid "ctxcomponents_tickettests_description"
307  msgstr "Espacio de Ligas hacia el caso de prueba de un Ticket"
@@ -299,10 +305,27 @@
308  "files related to this ticket (screenshot, file needed to reproduce a bug...)"
309  msgstr ""
310  "documentos ligados a este ticket(copia de pantalla, archivos necesarios para "
311  "la reproducción de una anomalia...)"
312 
313 +msgid "follow-up ticket"
314 +msgstr ""
315 +
316 +msgid "follow_up"
317 +msgstr ""
318 +
319 +msgctxt "Ticket"
320 +msgid "follow_up"
321 +msgstr ""
322 +
323 +msgid "follow_up_object"
324 +msgstr ""
325 +
326 +msgctxt "Ticket"
327 +msgid "follow_up_object"
328 +msgstr ""
329 +
330  msgid "forge ticket workflow"
331  msgstr "Workflow de Tickets (Forge)"
332 
333  msgid "forge.version.burndown_tab"
334  msgstr "Gráfica de Avance"
@@ -399,13 +422,10 @@
335  msgstr "Versión"
336 
337  msgid "moved"
338  msgstr "Desplazado"
339 
340 -msgid "moved away from published version"
341 -msgstr "Desplazado de la versión publicada"
342 -
343  msgid "n/a"
344  msgstr "n/a"
345 
346  msgctxt "ExtProject"
347  msgid "name"
@@ -416,10 +436,13 @@
348  msgstr "Nombre"
349 
350  msgid "no more maintained"
351  msgstr "Actualmente sin mantenimiento"
352 
353 +msgid "not validated"
354 +msgstr ""
355 +
356  msgid "open"
357  msgstr "abierto"
358 
359  msgid "planned_delivery"
360  msgstr "Entrega prevista"
@@ -491,10 +514,13 @@
361 
362  msgctxt "Project"
363  msgid "recommends_object"
364  msgstr "Recomendado por"
365 
366 +msgid "refuse validation"
367 +msgstr ""
368 +
369  msgid "reject"
370  msgstr "Rechazar"
371 
372  msgid "rejected"
373  msgstr "Rechazado"
diff --git a/i18n/fr.po b/i18n/fr.po
@@ -10,10 +10,13 @@
374  "Content-Type: text/plain; charset=UTF-8\n"
375  "Content-Transfer-Encoding: 8bit\n"
376  "Generated-By: pygettext.py 1.5\n"
377  "Plural-Forms: nplurals=2; plural=(n > 1);\n"
378 
379 +msgid "A new ticket following-up the former must be created"
380 +msgstr "Un nouveau ticket faisant suite au premier doit être créé"
381 +
382  msgid "All latest releases..."
383  msgstr "Toutes les dernières livraisons..."
384 
385  msgid "All new projects..."
386  msgstr "Tous les derniers projets..."
@@ -150,13 +153,10 @@
387  msgstr "attachement"
388 
389  msgid "burn down chart"
390  msgstr "graphique d'avancement"
391 
392 -msgid "codebrowser_tab"
393 -msgstr "code source"
394 -
395  msgctxt "Card"
396  msgid "comments_object"
397  msgstr "commenté par"
398 
399  msgctxt "Email"
@@ -196,10 +196,16 @@
400  msgstr "ajout d'une saisie d'écran pour le projet %(linkto)s"
401 
402  msgid "creating File (Ticket %(linkto)s attachment File)"
403  msgstr "ajout d'un attachement pour le ticket %(linkto)s"
404 
405 +msgid "ctxcomponents_forge.followup_history"
406 +msgstr "boite de traçabilité d'un ticket"
407 +
408 +msgid "ctxcomponents_forge.followup_history_description"
409 +msgstr ""
410 +
411  msgid "ctxcomponents_tickettests"
412  msgstr "section test (ticket)"
413 
414  msgid "ctxcomponents_tickettests_description"
415  msgstr "bloc de liens vers les cas de tests d'un ticket"
@@ -300,10 +306,27 @@
416  "files related to this ticket (screenshot, file needed to reproduce a bug...)"
417  msgstr ""
418  "fichiers liés à ce ticket (saisie d'écran, fichier nécessaire à la "
419  "reproduction d'une anomalie...)"
420 
421 +msgid "follow-up ticket"
422 +msgstr "suite du ticket"
423 +
424 +msgid "follow_up"
425 +msgstr "suite de"
426 +
427 +msgctxt "Ticket"
428 +msgid "follow_up"
429 +msgstr "suite de"
430 +
431 +msgid "follow_up_object"
432 +msgstr "suivi par"
433 +
434 +msgctxt "Ticket"
435 +msgid "follow_up_object"
436 +msgstr "suivi par"
437 +
438  msgid "forge ticket workflow"
439  msgstr "workflow des tickets (forge)"
440 
441  msgid "forge.version.burndown_tab"
442  msgstr "graphe d'avancement"
@@ -400,13 +423,10 @@
443  msgstr "version"
444 
445  msgid "moved"
446  msgstr "déplacé"
447 
448 -msgid "moved away from published version"
449 -msgstr "déplacé de la version publiée"
450 -
451  msgid "n/a"
452  msgstr "n/a"
453 
454  msgctxt "ExtProject"
455  msgid "name"
@@ -417,10 +437,13 @@
456  msgstr "nom"
457 
458  msgid "no more maintained"
459  msgstr "plus maintenu"
460 
461 +msgid "not validated"
462 +msgstr "non validé"
463 +
464  # bug/story states
465  msgid "open"
466  msgstr "ouvert"
467 
468  msgid "planned_delivery"
@@ -493,10 +516,13 @@
469 
470  msgctxt "Project"
471  msgid "recommends_object"
472  msgstr "recommandé par"
473 
474 +msgid "refuse validation"
475 +msgstr "refuser la validation"
476 +
477  msgid "reject"
478  msgstr "rejeter"
479 
480  msgid "rejected"
481  msgstr "rejeté"
diff --git a/migration/1.7.0_Any.py b/migration/1.7.0_Any.py
@@ -1,2 +1,21 @@
482  drop_attribute('Project', 'vcsurl')
483  drop_attribute('Project', 'reporturl')
484 +
485 +
486 +add_relation_definition('Ticket', 'follow_up', 'Ticket')
487 +
488 +# Update the Ticket workflow
489 +
490 +from cubes.tracker.schemaperms import xperm
491 +
492 +wf = get_workflow_for('Ticket')
493 +vp = wf.state_by_name('validation pending')
494 +notvalidated = wf.add_state('not validated')
495 +wf.add_transition(_('refuse validation'), (vp,), notvalidated,
496 +                  ('managers', 'staff'), xperm('client'))
497 +reopen = wf.transition_by_name('reopen')
498 +rql('DELETE S allowed_transition TR WHERE S eid %(s)s, TR eid %(tr)s',
499 +    {'s': vp.eid, 'tr': reopen.eid})
500 +commit()
501 +
502 +sync_schema_props_perms('done_in')
diff --git a/migration/postcreate.py b/migration/postcreate.py
@@ -35,10 +35,11 @@
503  inprogress = twf.add_state(_('in-progress'))
504  done       = twf.add_state(_('done'))
505  vp         = twf.add_state(_('validation pending'))
506  resolved   = twf.add_state(_('resolved'))
507  deprecated = twf.add_state(_('deprecated'))
508 +notvalidated = twf.add_state(_('not validated'))
509 
510  twf.add_transition(_('start'), open, inprogress,
511                     ('managers', 'staff'), xperm('developer'))
512  twf.add_transition(_('reject'), (open, inprogress), rejected,
513                     ('managers', 'staff'), xperm('developer'))
@@ -48,11 +49,13 @@
514                     ('managers', 'staff'), xperm('developer'))
515  twf.add_transition(_('deprecate'), open, deprecated,
516                     ('managers', 'staff'), xperm('client', 'developer'))
517  twf.add_transition(_('resolve'), vp, resolved,
518                     ('managers', 'staff'), xperm('client'))
519 -twf.add_transition(_('reopen'), (done, vp, rejected), open,
520 +twf.add_transition(_('reopen'), (done, rejected), open,
521 +                   ('managers', 'staff'), xperm('client'))
522 +twf.add_transition(_('refuse validation'), (vp,), notvalidated,
523                     ('managers', 'staff'), xperm('client'))
524  twf.add_transition(_('wait for feedback'), (open, inprogress), waiting,
525                     ('managers', 'staff'), xperm('developer'))
526  twf.add_transition(_('got feedback'), waiting, None, # go back transition
527                     ('managers', 'staff'), xperm('client', 'developer'))
diff --git a/schema.py b/schema.py
@@ -26,13 +26,13 @@
528  from yams.buildobjs import (EntityType, RelationDefinition,
529                              String, Float, RichString)
530 
531  from cubicweb.schema import (RQLVocabularyConstraint, RRQLExpression,
532                               ERQLExpression, make_workflowable)
533 -from cubes.tracker.schemaperms import sexpr, xexpr, xrexpr, xorexpr, xperm
534 +from cubes.tracker.schemaperms import sexpr, xexpr, xrexpr, xorexpr, xperm, restricted_oexpr
535 
536 -from cubes.tracker.schema import Project, Ticket, Version
537 +from cubes.tracker.schema import Project, Ticket, Version, done_in
538  from cubes.file.schema import File
539  from cubes.card.schema import Card
540 
541  Project.add_relation(String(maxsize=128,
542                              description=_('url to project\'s home page. Leave this field '
@@ -48,10 +48,15 @@
543                              'NOT O uses S')
544      ]
545 
546  make_workflowable(Project)
547 
548 +done_in.__permissions__['delete'] = (
549 +    'managers',
550 +    RRQLExpression('U in_group G, G name "staff", NOT (O in_state ST, ST name "published")'),
551 +    restricted_oexpr('O in_state ST, ST name "planned"', 'client'),)
552 +
553  class recommends(RelationDefinition):
554      __permissions__ = {
555          'read':   ('managers', 'users', 'guests'),
556          'add':    ('managers', RRQLExpression('U has_update_permission S', 'S'),),
557          'delete': ('managers', RRQLExpression('U has_update_permission S', 'S'),),
@@ -131,10 +136,20 @@
558                                      # XXX use cost is NULL instead
559                                      ERQLExpression(xperm('client')+', X in_state S, S name "open"'),
560                                      ERQLExpression('X owned_by U, X in_state S, S name "open"'),
561                                      )
562 
563 +class follow_up(RelationDefinition):
564 +    """link a ticket to another, not validated, ticket"""
565 +    __permissions__ = {
566 +        'read':   ('managers', 'users', 'guests'),
567 +        'add':    ('managers', 'staff', RRQLExpression(xperm('client'))),
568 +        'delete': ('managers',),
569 +        }
570 +    subject = 'Ticket'
571 +    object = 'Ticket'
572 +    cardinality = '??'
573 
574  class attachment(RelationDefinition):
575      __permissions__ = {
576          'read':   ('managers', 'users', 'guests'),
577          # also used for Email attachment File
diff --git a/test/unittest_forge.py b/test/unittest_forge.py
@@ -1,15 +1,16 @@
578  """Forge unit tests"""
579 +from __future__ import with_statement
580 
581  from datetime import datetime, timedelta
582 
583  from logilab.common.testlib import unittest_main, SkipTest
584 
585  from cubicweb.devtools import ApptestConfiguration
586  from cubicweb.devtools.testlib import AutoPopulateTest
587 
588 -from cubicweb import ValidationError
589 +from cubicweb import ValidationError, Unauthorized
590  from cubicweb import NoSelectableObject
591  from cubicweb.web.views import actions, workflow, idownloadable
592 
593  from cubes.tracker.testutils import TrackerBaseTC
594  from cubes.tracker.views import ticket, document
@@ -20,10 +21,24 @@
595  ONEDAY = timedelta(1)
596 
597  class ProjectTC(TrackerBaseTC):
598      """Project"""
599 
600 +    def test_followup_wf(self):
601 +        req = self.request()
602 +        ticket = self.execute('INSERT Ticket X: X title "parent", X concerns P, X load 1 '
603 +                              'WHERE P is Project').get_entity(0, 0)
604 +        self.commit()
605 +        iworkflowable = ticket.cw_adapt_to('IWorkflowable')
606 +        for tr, state in [('done', 'done'), 
607 +                          ('ask validation', 'validation pending'),
608 +                          ('refuse validation', 'not validated')]:
609 +            iworkflowable.fire_transition(tr)
610 +            self.commit()
611 +            ticket.clear_all_caches()
612 +            self.assertEqual(iworkflowable.state, state)
613 +
614      def test_tickets_rql(self):
615          self.execute(self.cubicweb.active_tickets_rql()) # just check rql validity
616          self.execute(*self.cubicweb.tickets_rql()) # just check rql validity
617 
618      def test_download_box(self):
@@ -406,12 +421,12 @@
619          req = self.request()
620          rset = req.execute('Any X WHERE X is Ticket')
621          self.assertListEqual(self.action_submenu(req, rset, 'workflow'),
622                                [(u'resolve', u'http://testing.fr/cubicweb/ticket/%s?treid=%s&vid=statuschange' %
623                                  (self.t1.eid, wf.transition_by_name('resolve').eid)),
624 -                               (u'reopen', u'http://testing.fr/cubicweb/ticket/%s?treid=%s&vid=statuschange' %
625 -                                (self.t1.eid, wf.transition_by_name('reopen').eid)),
626 +                               (u'refuse validation', u'http://testing.fr/cubicweb/ticket/%s?treid=%s&vid=statuschange' %
627 +                                (self.t1.eid, wf.transition_by_name('refuse validation').eid)),
628                                 (u'view workflow', u'http://testing.fr/cubicweb/workflow/%s'  % wf.eid),
629                                 (u'view history', u'http://testing.fr/cubicweb/ticket/%s?vid=wfhistory' % self.t1.eid)])
630          self.t1.cw_adapt_to('IWorkflowable').fire_transition('resolve')
631          self.commit()
632          req = self.request()
@@ -463,16 +478,10 @@
633          v2.clear_all_caches()
634          v2iwf.change_state('published')
635          self.commit()
636          self.t1.clear_all_caches()
637          self.assertEqual(t1iwf.state, 'validation pending')
638 -        # # if the ticket is then moved to no version or to an unpublished version,
639 -        # # reopen it.
640 -        self.t1.set_relations(done_in=self.v)
641 -        self.commit()
642 -        self.t1.clear_all_caches()
643 -        self.assertEqual(t1iwf.state, 'open')
644 
645      def test_version_publishing_cant_change_ticket_state(self):
646          self.execute('INSERT CWGroup X: X name "cubicwebdevelopers"')
647          self.grant_permission(self.session, self.cubicweb, 'cubicwebdevelopers',
648                                u'developer')
@@ -490,15 +499,28 @@
649          self.commit()
650          # - publish version as client
651          self.login('prj1client')
652          v2 =  self.execute('Any X WHERE X version_of P, P name "cubicweb", P is Project, X num "0.0.0"').get_entity(0, 0)
653          t1 =  self.execute('Any X WHERE X in_state S, S name SN, X eid %s'% self.t1.eid).get_entity(0, 0)
654 +        # - can't change ticket's state
655          t1iwf= t1.cw_adapt_to('IWorkflowable')
656          self.assertRaises(ValidationError, t1iwf.fire_transition, 'ask validation')
657 +        self.rollback()
658          v2.cw_adapt_to('IWorkflowable').fire_transition('publish')
659 +        self.commit()
660          # - verify that ticket on which permissions were revoked remains in state
661          self.assertEqual(t1iwf.state, 'done')
662 +        # only managers are enabled to move away ticket from a published version
663 +        self.assertRaises(Unauthorized, self.execute,
664 +                          'DELETE X done_in V WHERE X eid %(x)s', {'x': t1.eid})
665 +        self.rollback()
666 +        self.restore_connection()
667 +        self.create_user(self.session, 'staffuser', groups=('users', 'staff',))
668 +        with self.login('staffuser'):
669 +            self.assertRaises(Unauthorized, self.execute,
670 +                              'DELETE X done_in V WHERE X eid %(x)s', {'x': t1.eid})
671 +            self.rollback()
672 
673 
674  class CommentTC(AutoPopulateTest):
675      no_auto_populate = ('TestInstance',)
676      ignored_relations = set(('nosy_list',))
@@ -517,10 +539,11 @@
677              try:
678                  comment.project
679              except AttributeError:
680                  self.fail('%s class does not implement the project property' % etype)
681 
682 +
683  class FTITC(TrackerBaseTC):
684      config = ApptestConfiguration('data', apphome=CommentTC.datadir,
685                                    sourcefile='sources_fti')
686      @classmethod
687      def _init_repo(cls, *args, **kwargs):
diff --git a/test/unittest_security.py b/test/unittest_security.py
@@ -131,13 +131,15 @@
688          #self._test_tr_success('staffuser', b.eid, 'resolve')
689          # managers can do what they want, even going to a state without existing transition...
690          b.cw_adapt_to('IWorkflowable').change_state('validation pending')
691          b._cw.cnx.commit()
692          # only staff/client can pass the reopen transition
693 -        self._test_tr_fail('stduser', b.eid, 'reopen')
694 -        self._test_tr_fail('prj1developer', b.eid, 'reopen')
695 -        self._test_tr_success('prj1client', b.eid, 'reopen')
696 +        self._test_tr_fail('stduser', b.eid, 'refuse validation')
697 +        self._test_tr_fail('prj1developer', b.eid, 'refuse validation')
698 +        self._test_tr_success('prj1client', b.eid, 'refuse validation')
699 +        b.cw_adapt_to('IWorkflowable').change_state('open')
700 +        b._cw.cnx.commit()
701          cnx = self.mylogin('prj1developer')
702          # only staff/developer can pass the reject transition
703          self._test_tr_fail('prj1client', b.eid, 'reject')
704          self._test_tr_success('prj1developer', b.eid, 'reject')
705          # staff/developer/client can pass the deprecate transition
diff --git a/views/__init__.py b/views/__init__.py
@@ -77,10 +77,13 @@
706  _pvs.tag_object_of(('*', 'documented_by', '*'), 'hidden')
707 
708  _pvs.tag_subject_of(('Ticket', 'attachment', '*'), 'sideboxes')
709  _pvs.tag_object_of(('*', 'generate_bug', 'Ticket'), 'sideboxes')
710 
711 +_pvs.tag_subject_of(('Ticket', 'follow_up', '*'), 'hidden')
712 +_pvs.tag_object_of(('*', 'follow_up', 'Ticket'), 'hidden')
713 +
714  _pvs.tag_attribute(('ExtProject', 'name'), 'hidden')
715 
716  _pvs.tag_attribute(('License', 'name'), 'hidden')
717  _pvs.tag_attribute(('License', 'url'), 'hidden')
718 
diff --git a/views/boxes.py b/views/boxes.py
@@ -1,20 +1,21 @@
719  """forge components boxes
720 
721  :organization: Logilab
722 -:copyright: 2006-2010 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
723 +:copyright: 2006-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
724  :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
725  """
726  __docformat__ = "restructuredtext en"
727 
728  from logilab.mtconverter import xml_escape
729 
730  from cubicweb.schema import display_name
731 -from cubicweb.selectors import is_instance, score_entity
732 +from cubicweb.selectors import is_instance, score_entity, has_related_entities
733  from cubicweb.view import EntityAdapter
734  from cubicweb.web import component
735  from cubicweb.web.views import boxes
736 +from cubicweb.utils import transitive_closure_of
737 
738  class VersionIDownloadableAdapter(EntityAdapter):
739      __regid__ = 'IDownloadable'
740      __select__ = (is_instance('Version') &
741                    score_entity(lambda x: x.project.downloadurl and x.cw_adapt_to('IWorkflowable').state == 'published'))
@@ -54,10 +55,38 @@
742               xml_escape(version.tarball_name())))
743          w(u' [<a href="%s">%s</a>]' % (
744              project.downloadurl, self._cw._('see them all')))
745 
746 
747 +class FollowupSideboxView(component.EntityCtxComponent):
748 +    __regid__ = 'forge.followup_history'
749 +    __select__ = (component.EntityCtxComponent.__select__ &
750 +                  is_instance('Ticket') &
751 +                  has_related_entities('follow_up', 'object') |
752 +                  has_related_entities('follow_up', 'subject'))
753 +
754 +    title = 'ticket traceability chain'
755 +    context = 'incontext'
756 +    wrapper = '<dt class="%s">%s - %s</dt>'
757 +
758 +    def render_body(self, w):
759 +        self._cw.add_css('cubes.forge.css')
760 +        ticket = self.entity
761 +        parent = transitive_closure_of(ticket, 'follow_up')
762 +        children = transitive_closure_of(ticket, 'reverse_follow_up')
763 +        w(u'<dl id="tabs">')
764 +        for elt in reversed(list(parent)[1:]):
765 +            w(self.wrapper % ('other',
766 +                              elt.view('incontext'),
767 +                              elt.cw_adapt_to('IWorkflowable').state))
768 +        w(self.wrapper % ('current', ticket.view('incontext'),
769 +                          ticket.cw_adapt_to('IWorkflowable').state))
770 +        for elt in list(children)[1:]:
771 +            w(self.wrapper % ('other',
772 +                              elt.view('incontext'),
773 +                              elt.cw_adapt_to('IWorkflowable').state))
774 +        w(u'</dl>')
775 
776  class ImageSideboxView(boxes.RsetBox): # XXX Project.screenshots / Ticket.attachment
777      __select__ = boxes.RsetBox.__select__ & is_instance('File')
778 
779      def render_body(self, w):
diff --git a/views/forms.py b/views/forms.py
@@ -0,0 +1,72 @@
780 +from copy import copy
781 +
782 +from cubicweb.web import stdmsgs, formwidgets, eid_param
783 +from cubicweb.web.formfields import RelationField
784 +from cubicweb.web.views.workflow import ChangeStateFormView
785 +from cubicweb.selectors import is_instance, rql_condition, is_in_state, match_transition
786 +
787 +class NotValidatedChangeView(ChangeStateFormView):
788 +
789 +    __select__ = (ChangeStateFormView.__select__ &
790 +                  is_instance('Ticket') &
791 +                  is_in_state('validation pending') &
792 +                  match_transition('follow-up'))
793 +
794 +    def cell_call(self, row, col):
795 +        ""
796 +        entity = self.cw_rset.get_entity(row, col)
797 +        transition = self._cw.entity_from_eid(self._cw.form['treid'])
798 +        form = self.get_form(entity, transition)
799 +        self.w(u'<h4>%s %s</h4>\n' % (self._cw._(transition.name),
800 +                                      entity.view('oneline')))
801 +        msg = self._cw.__('status will change from %(st1)s to %(st2)s') % {
802 +            'st1': entity.cw_adapt_to('IWorkflowable').printable_state,
803 +            'st2': self._cw._(transition.destination(entity).name)}
804 +        self.w(u'<p>%s</p>\n' % msg)
805 +        self.w('<h4>%s</h4>\n' % self._cw._('A new ticket following-up the former must be created'))
806 +        form.render(w=self.w)
807 +
808 +    def get_form(self, entity, transition, **kwargs):
809 +        """Select the ticket creation form and mixed it with the
810 +        workflow comment form
811 +        """
812 +        buttons = [formwidgets.SubmitButton(),
813 +                   formwidgets.Button(stdmsgs.BUTTON_CANCEL, cwaction='cancel')]
814 +        entity.complete()
815 +        entity.cw_attr_cache.pop('load_left', None)
816 +        followup_tk = copy(entity)
817 +        followup_tk.eid = self._cw.varmaker.next()
818 +        form = self._cw.vreg['forms'].select('composite', self._cw,
819 +                                             domid='followupdform',
820 +                                             form_buttons=buttons,
821 +                                             form_renderer_id = 'base',
822 +                                             redirect_path=self.redirectpath(entity),
823 +                                             **kwargs)
824 +
825 +        followup_form = self._cw.vreg['forms'].select('edition', self._cw,
826 +                                                       entity=followup_tk,
827 +                                                       mainform=False)
828 +        followup_form.add_hidden(eid_param('__cloned_eid',followup_tk.eid),
829 +                                  entity.eid)
830 +        followup_relation_field = RelationField(name='follow_up',
831 +                                                 label=self._cw._('follow-up ticket'),
832 +                                                 role='subject',
833 +                                                 eidparam=True,
834 +                                                 choices=[(entity.dc_title(), entity.eid)])
835 +        followup_form.append_field(followup_relation_field)
836 +        form.add_subform(followup_form)
837 +        trinfo = self._cw.vreg['etypes'].etype_class('TrInfo')(self._cw)
838 +        trinfo.eid = self._cw.varmaker.next()
839 +        wf_form =  self._cw.vreg['forms'].select('edition',
840 +                                                 self._cw,
841 +                                                 entity=trinfo,
842 +                                                 mainform=False)
843 +        wf_form.field_by_name('wf_info_for', 'subject').value = entity.eid
844 +        trfield = wf_form.field_by_name('by_transition', 'subject')
845 +        trfield.widget = formwidgets.HiddenInput()
846 +        trfield.value = transition.eid
847 +        form.add_subform(wf_form)
848 +        return form
849 +
850 +def registration_callback(vreg):
851 +    vreg.register(NotValidatedChangeView)
diff --git a/views/ticket.py b/views/ticket.py
@@ -5,14 +5,14 @@
852  :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
853  """
854  __docformat__ = "restructuredtext en"
855  _ = unicode
856 
857 -from cubicweb.selectors import one_line_rset, is_instance
858 -from cubicweb.web import component
859 -from cubicweb.web.views import tabs
860 -
861 +from logilab.mtconverter import xml_escape
862 +from cubicweb.web import component, box
863 +from cubicweb.web.views import tabs, primary
864 +from cubicweb.selectors import one_line_rset, is_instance, has_related_entities
865 
866  class TicketTestCardCtxComponent(component.RelatedObjectsCtxComponent):
867      """display project's test cards"""
868      __regid__ = 'tickettests'
869      __select__ = component.RelatedObjectsCtxComponent.__select__ & is_instance('Ticket')
@@ -22,11 +22,10 @@
870 
871      title = _('Test cards')
872      context = 'navcontentbottom'
873      order = 30
874 
875 -
876  class TicketScreenshotsView(tabs.EntityRelationView):
877      """display ticket's screenshots """
878      __regid__ = 'ticketscreenshots'
879      __select__ = one_line_rset() & tabs.EntityRelationView.__select__ & is_instance('Ticket')
880