├── LICENSE ├── README.md └── doorhole.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Luca 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # doorhole 2 | 3 | A graphical requirements editor for doorstop: https://github.com/doorstop-dev/doorstop 4 | 5 | This tool is designed to view and edit doorstop requirements, as fast as possible. 6 | 7 | ![immagine](https://user-images.githubusercontent.com/301673/104736138-cdf86c00-5742-11eb-9a24-89f2f3f04da1.png) 8 | 9 | 10 | ## Installation 11 | 12 | 1. Install the required dependencies (Python 3.5+ required by doorstop): 13 | 14 | ``` 15 | pip install doorstop 16 | pip install PySide6 17 | pip install plantuml-markdown 18 | ``` 19 | 20 | 2. Download the `doorhole.py` script inside a directory listed in your PATH system variable for easier access 21 | 3. Make it executable 22 | 23 | 24 | ## Usage 25 | 26 | 1. Open a terminal inside your git folder (you should already have a doorstop requirement tree). 27 | 2. Launch the script with `./doorhole.py`. 28 | 29 | It will launch `doorstop` internally, load all requirements, and after a while the editor window will appear. 30 | 31 | 32 | ## Why? 33 | 34 | Because all the tools lack something: 35 | 36 | - doorstop GUI is slow while editing 37 | - editing requirements using Calc or Excel is cumbersome 38 | - you don't see PlantUML graphs (but you love them) 39 | 40 | ## Features 41 | 42 | - faster than doorstop-gui 43 | - open all requirement sets at once with tabs 44 | - display requirements in a table-like interface 45 | - edit and actually see plantUML graphs! 46 | - create, view, edit, delete requirements 47 | 48 | ## Un-features 49 | 50 | This tool is not performing a whole doorstop verification, because it would be rather slow (like doorstop GUI). 51 | Use `doorstop` for that, and add your own checks there. 52 | 53 | ## PlantUML rendering 54 | 55 | The script uses by default the PlantUML online renderer at http://www.plantuml.com/plantuml. 56 | 57 | If you experience issues with the above online renderer, you can: 58 | 59 | - use another web renderer (Docker image: https://hub.docker.com/r/plantuml/plantuml-server), or 60 | - install a local renderer 61 | 62 | If you want to change the PlantUML web renderer, update line #24 of the `doorhole.py` accordingly. 63 | 64 | If you want to install PlantUML locally, read on! 65 | 66 | ### Local PlantUML installation (Windows) 67 | 68 | 1. Download and install Java 69 | 2. Download `plantuml.jar` from https://plantuml.com/download inside a directory listed in your PATH system variable for easier access. 70 | 3. Next to the `plantuml.jar` file, create the `plantuml.bat` file with the following content: 71 | 72 | ``` 73 | @echo off 74 | set mypath=%~dp0 75 | setlocal 76 | java -jar %mypath%\plantuml.jar -charset UTF-8 %* 77 | ``` 78 | 79 | Done! 80 | 81 | ## (Lack of) Math equations 82 | 83 | The sad news is that this tool cannot render MathJax expressions right now. It's a Javascript loaded in the published HTML. 84 | -------------------------------------------------------------------------------- /doorhole.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Checks functional requirements with regexes 4 | # Builds functional matrix 5 | 6 | import doorstop 7 | from doorstop.core.types import iter_documents, iter_items, Level 8 | import os 9 | import sys 10 | from PySide6.QtWidgets import * 11 | from PySide6.QtCore import * 12 | from PySide6.QtGui import * 13 | from PySide6.QtWebEngineWidgets import * 14 | import logging 15 | import markdown 16 | from plantuml_markdown import PlantUMLMarkdownExtension 17 | import tempfile 18 | import copy 19 | 20 | EXTENSIONS = ( 21 | 'markdown.extensions.extra', 22 | 'markdown.extensions.sane_lists', 23 | PlantUMLMarkdownExtension( 24 | server='http://www.plantuml.com/plantuml', 25 | cachedir=tempfile.gettempdir(), 26 | format='svg', 27 | classes='class1,class2', 28 | title='UML', 29 | alt='UML Diagram', 30 | ), 31 | ) 32 | 33 | logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) 34 | logging.getLogger('doorstop').setLevel(logging.WARNING) 35 | logging.getLogger('MARKDOWN').setLevel(logging.WARNING) 36 | logger = logging.getLogger 37 | log = logger(__name__) 38 | 39 | 40 | # requirements tree is a global because it's shared by all classes. 41 | # Maybe it should become a singleton. 42 | reqtree = None 43 | 44 | class RequirementsDelegate(QStyledItemDelegate): 45 | def __init__(self, parent=None): 46 | super(RequirementsDelegate, self).__init__(parent) 47 | self.doc = QTextDocument(self) 48 | self.docIndex = None 49 | self.h = None 50 | self.w = None 51 | self.md = markdown.Markdown(extensions=EXTENSIONS) 52 | 53 | def createEditor(self, parent, option, index): 54 | if index.model()._headerData[index.column()] == 'text': 55 | edit = QPlainTextEdit(parent) 56 | # set fixed font 57 | fixed_font = QFontDatabase.systemFont(QFontDatabase.FixedFont) 58 | fixed_font.setStyleHint(QFont.TypeWriter) 59 | edit.setFont(fixed_font) 60 | # set colors 61 | edit.setStyleSheet(""" 62 | QPlainTextEdit { 63 | color: black; 64 | background: white; 65 | } 66 | """) 67 | return edit 68 | return super(RequirementsDelegate, self).createEditor(parent, option, index) # editor chosen with the QtEditRole in model.data() 69 | 70 | def setEditorData(self, editor, index): 71 | if index.model()._headerData[index.column()] == 'text': 72 | editor.insertPlainText(index.data()) 73 | if index.model()._headerData[index.column()] == 'level': # would create an empty QLineEdit otherwise 74 | editor.setText(index.data()) 75 | return super(RequirementsDelegate, self).setEditorData(editor, index) 76 | 77 | def setModelData(self, editor, model, index): # called after closing the editor 78 | # We need to extract a value. Possible editors: https://doc.qt.io/qtforpython/PySide2/QtWidgets/QItemEditorFactory.html 79 | 80 | # There ought to be be a better way. 81 | editorType = str(type(editor)) 82 | if 'QComboBox' in editorType: 83 | model.setData(index, editor.currentText()) 84 | elif 'QLineEdit' in editorType: 85 | model.setData(index, editor.text()) 86 | elif 'QPlainTextEdit' in editorType: 87 | model.setData(index, editor.toPlainText()) 88 | 89 | def getDoc(self, option, index): # builds the doc inside self.doc, uses self.index as cache 90 | if self.docIndex == index: # Doc already done 91 | return 92 | 93 | # a new doc is to be rendered 94 | self.docIndex = index 95 | mdl = index.model() 96 | if mdl._headerData[index.column()] == 'text': 97 | item = mdl._data[index.row()][len(mdl._headerData)] # DS item cached in last column 98 | text = item.get('text') 99 | level = str(item.get('level')) 100 | header = str(item.get('header')) 101 | item_path = item.get('path') # doorstop property 'root' from DS item 102 | item_path = os.path.dirname(os.path.realpath(item_path)) 103 | 104 | # mimick DS title and header attributes 105 | lines = [l for l in text.splitlines()] 106 | heading = '' 107 | if level.endswith('.0'): # Chapter title 108 | heading += '#'*level.count('.') + ' ' + level[:-2] + ' ' 109 | if header.strip(): # use header as heading 110 | heading += header.strip() + '\n\n' 111 | if (len(lines)): # append text, if any 112 | lines = [heading] + lines 113 | else: 114 | lines = [heading] 115 | else: # use first line as heading 116 | if len(lines): # ...if any! 117 | heading += lines[0] + '\n\n' 118 | lines = [heading] + lines[1:] 119 | else: 120 | lines = [heading] 121 | else: # Requirement 122 | if header.strip(): # use header as heading 123 | heading += '#'*(level.count('.') +1) + ' ' + level + ' ' + header.strip() 124 | if item.normative: 125 | heading += ' (' + str(item.uid) + ')' 126 | else: # use UID as heading 127 | heading += '#'*(level.count('.') +1) + ' ' + level + ' ' + str(item.uid) 128 | lines = [heading] + lines 129 | text = '\n'.join(lines) 130 | 131 | # change work dir to where the reqs are stored, otherwise images will not be rendered 132 | cwd_bkp = os.getcwd() 133 | try: 134 | os.path.dirname(os.path.realpath(__file__)) 135 | os.chdir(item_path) # necessary to solve linked items with relative paths (e.g. images) 136 | html = self.md.convert(text) 137 | self.doc.setHtml(html) 138 | except Exception as e: 139 | warning = '**An error occurred while displaying the content**\n\n: '+ str(e) + '\n\n' 140 | text = warning + text 141 | self.doc.setMarkdown(text) 142 | os.chdir(cwd_bkp) 143 | 144 | # Document should be restricted to column width 145 | options = QStyleOptionViewItem(option) 146 | self.doc.setTextWidth(options.rect.width()) 147 | 148 | def paint(self, painter, option, index): 149 | mdl = index.model() 150 | if mdl._headerData[index.column()] == 'text': 151 | # get rich text document and paint it 152 | self.getDoc(option, index) 153 | ctx = QAbstractTextDocumentLayout.PaintContext() 154 | painter.save() 155 | painter.translate(option.rect.topLeft()); 156 | painter.setClipRect(option.rect.translated(-option.rect.topLeft())) 157 | self.doc.documentLayout().draw(painter, ctx) 158 | painter.restore() 159 | else: 160 | super(RequirementsDelegate, self).paint(painter, option, index) 161 | 162 | def sizeHint(self, option, index): 163 | mdl = index.model() 164 | if mdl._headerData[index.column()] == 'text': 165 | # get rich text document and size it 166 | self.getDoc(option, index) 167 | #log.debug(mdl._headerData[index.column()] + "\t W: " + str(self.doc.idealWidth()) + " H: " + str(self.doc.size().height())) 168 | return QSize(self.doc.idealWidth(), self.doc.size().height()) 169 | else: 170 | return QSize(0,0) 171 | #super(RequirementsDelegate, self).sizeHint(option, index) 172 | 173 | class RequirementSetModel(QAbstractTableModel): 174 | def __init__(self, docId=None, parent=None): 175 | super(RequirementSetModel, self).__init__(parent) 176 | self._docId = docId 177 | self.load() 178 | 179 | @Slot() 180 | def load(self): 181 | global reqtree 182 | self._document = reqtree.find_document(self._docId) 183 | 184 | # Requirements attributes 185 | # ----------------------- 186 | # 187 | # Requirements attributes will be the column names in the table view. 188 | # 189 | # There are: 190 | # - standard attributes 191 | # - extended attributes (within single requirement) 192 | # - extended attributes with defaults (declared in document) 193 | # - extended attributes that concur to review timestamp (declared in document) 194 | # 195 | # Attribute names are the keys of items[x]._data 196 | # We do a first loop to gather all user-defined attributes 197 | 198 | # Standard data (pulled from doorstop.item inspection) 199 | stdHeaderData = {'path', 'root', 'active', 'normative', 'uid', 'level', 'header', 'text', 'derived', 'ref', 'references', 'reviewed', 'links'} 200 | 201 | headerData = [] 202 | for item in iter_items(self._document): 203 | headerData += list(item._data.keys()) 204 | headerData = list(set(headerData)) # drop duplicates 205 | 206 | # Non-standard data that we will display in more columns: 207 | userHeaderData = set(headerData) - stdHeaderData 208 | if userHeaderData: 209 | log.debug('['+str(self._document)+'] Custom requirements attributes: ' + str(userHeaderData)) 210 | 211 | # And we have now the column names. 212 | # We put 'text' always to the last column because it usually is stretched. 213 | # The 'active' field is always true - inactive requirements are not shown at all. Doorstop doesn't tell us about them. 214 | self._headerData = ['uid', 'path', 'root', 'normative', 'derived', 'reviewed', 'level', 'header', 'ref', 'references', 'links'] + list(userHeaderData) + ['text'] 215 | 216 | # Another loop to fill in the table rows 217 | self._data = [] 218 | for item in iter_items(self._document): 219 | row = [] 220 | for f in self._headerData: 221 | row.append(str(item.get(f))) 222 | row.append(item) # Doorstop item reference cached in the last row 223 | self._data.append(row) 224 | log.debug('['+str(self._document)+'] Requirements reloaded') 225 | 226 | # TableView methods that must be implemented 227 | def rowCount(self, index): 228 | return len(self._data) 229 | 230 | def columnCount(self, index): 231 | return len(self._headerData) 232 | 233 | def data(self, index, role=Qt.DisplayRole): 234 | if not index.isValid(): 235 | return None 236 | 237 | item = self._data[index.row()][len(self._headerData)] 238 | colName = self._headerData[index.column()] 239 | 240 | if role == Qt.DisplayRole: #------------------------------------- Value 241 | return str(item.get(colName)) 242 | 243 | if role == Qt.EditRole: 244 | return item.get(colName) 245 | 246 | if role == Qt.BackgroundRole: #------------------------------------- BG 247 | if not item.get('normative') or str(item.get('level')).endswith('.0'): 248 | return QBrush(QColor('lightGray')) 249 | 250 | if role == Qt.ForegroundRole: #------------------------------------- FG 251 | if not item.get('normative') or str(item.get('level')).endswith('.0'): 252 | return QBrush(QColor('gray')) 253 | 254 | def headerData(self, num, orientation, role=Qt.DisplayRole): 255 | 256 | if orientation == Qt.Horizontal: # ---------------------- Column header 257 | if role == Qt.DisplayRole: #--------------------------------- Value 258 | return self._headerData[num] 259 | if role == Qt.ForegroundRole: # -------------------------------- FG 260 | # custom attributes: blue 261 | if num > 10 and num < len(self._headerData) - 1: 262 | return QBrush(QColor('blue')) 263 | 264 | if orientation == Qt.Vertical: #---------------------------- Row header 265 | item = self._data[num][len(self._headerData)] 266 | if role == Qt.DisplayRole: #--------------------------------- Value 267 | return str(item.get('uid')) 268 | if role == Qt.ForegroundRole: #--------------------------------- FG 269 | # wrong items: red (TODO) 270 | # unreviewed items: orange 271 | if not item.get('reviewed'): 272 | return QBrush(QColor('orange')) 273 | # non-normative items: gray 274 | if not item.get('normative') or str(item.get('level')).endswith('.0'): # non-normative items: dark gray 275 | return QBrush(QColor('gray')) 276 | # OK items: green 277 | return QBrush(QColor('darkGreen')) # OK items 278 | if role == Qt.ToolTipRole: #------------------------------------ TT 279 | tt = "Reviewed: " + str(item.get('reviewed')) 280 | return tt 281 | return QAbstractTableModel.headerData(self, num, orientation, role) 282 | 283 | def flags(self, index): 284 | return Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsSelectable 285 | 286 | def setData(self, index, text): 287 | item = self._data[index.row()][len(self._headerData)] 288 | attr = self._headerData[index.column()] 289 | 290 | # Boolean values are passed as "True" or "False" strings, so we need to determine whether the original datatype was boolean. 291 | if type(item.get(attr)) == bool: 292 | if text == 'True': 293 | text = True 294 | else: 295 | text = False 296 | 297 | # Integer values are passed as strings, so we need to convert back to integer 298 | if type(item.get(attr)) == int: 299 | text = int(text) 300 | 301 | # Strings are left as they are 302 | if item.get(attr) != text: 303 | try: 304 | attributes = { attr : text } 305 | item.set_attributes(attributes) 306 | item.save() 307 | self._data[index.row()][index.column()] = item.get(attr) 308 | log.debug('Updated requirement [' + str(item.get('uid')) + '] attribute ['+attr+']') 309 | except doorstop.DoorstopError: 310 | log.error('Requirement [' + str(item.get('uid')) + '] file not saved - manual edit required: ' + path) 311 | self.layoutChanged.emit() 312 | 313 | def newReq(self, level=None): 314 | global reqtree 315 | if level is not None: 316 | item = reqtree.add_item(value=str(self._docId), level=level) 317 | log.debug("["+str(self._docId)+"] Added requirement " + str(item)) 318 | if item.get('level').heading: # make title items non-normative by default 319 | item.set('normative', False) 320 | item.set('derived', False) # set 'derived' property to False by default 321 | self.load() # reload the whole document 322 | self.layoutChanged.emit() 323 | 324 | def delReq(self, row): 325 | global reqtree 326 | item = self._data[row][len(self._headerData)] 327 | reqid = str(item) 328 | item.delete() # doorstop item deletion 329 | log.debug("["+str(self._docId)+"] Deleted requirement " + reqid) 330 | self.load() # reload the whole document 331 | self.layoutChanged.emit() 332 | 333 | def insertRowBefore(self, qidx): 334 | row = qidx.row() 335 | if row < len(self._data): # clicked requirement actually exists 336 | item = self._data[row][len(self._headerData)] 337 | new_level = Level(item.get('level')) # just use the level of the clicked req 338 | self.newReq(new_level) 339 | 340 | def insertRowAfter(self, qidx): 341 | row = qidx.row() 342 | if row < len(self._data): # clicked requirement actually exists 343 | item = self._data[row][len(self._headerData)] 344 | new_level = self._getSubsequentLevel(item.get('level')) 345 | self.newReq(new_level) 346 | 347 | def _getSubsequentLevel(self, level): 348 | new_level = Level(level) # create a new object, the '=' operator does a reference 349 | if new_level.heading: 350 | parts = str(new_level).split('.') # adding +1 to level doesn't work as expected when the level is a heading 351 | parts[-1] = 1 # x.x.x.0 --> x.x.x.1 352 | new_level = Level(parts) 353 | else: 354 | new_level += 1 355 | return new_level 356 | 357 | def deactivateRow(self, qidx): 358 | row = qidx.row() 359 | if row < len(self._data): # clicked requirement actually exists 360 | item = self._data[row][len(self._headerData)] 361 | item.set('normative', False) 362 | 363 | def activateRow(self, qidx): 364 | row = qidx.row() 365 | if row < len(self._data): # clicked requirement actually exists 366 | item = self._data[row][len(self._headerData)] 367 | item.set('normative', True) 368 | 369 | def deriveRow(self, qidx): 370 | row = qidx.row() 371 | if row < len(self._data): # clicked requirement actually exists 372 | item = self._data[row][len(self._headerData)] 373 | item.set('derived', True) 374 | 375 | def underiveRow(self, qidx): 376 | row = qidx.row() 377 | if row < len(self._data): # clicked requirement actually exists 378 | item = self._data[row][len(self._headerData)] 379 | item.set('derived', False) 380 | 381 | def deleteRow(self, qidx): 382 | row = qidx.row() 383 | if row < len(self._data): # clicked requirement actually exists 384 | qm = QMessageBox() 385 | qm.setText(str("This will delete the requirement from disk.\nYou will not be able to recover it unless it's versioned.\n\nAre you absolutely sure?")) 386 | qm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) 387 | ret = qm.exec() 388 | if ret == QMessageBox.Yes: 389 | self.delReq(row) 390 | 391 | def getItem(self, qidx): 392 | row = qidx.row() 393 | if row < len(self._data): 394 | return self._data[row][len(self._headerData)] 395 | else: 396 | return None 397 | 398 | class RequirementManager(QWidget): 399 | ''' 400 | Requirement document viewer with editor. 401 | The display format is a table. 402 | The model uses doorstop as source. 403 | 404 | Allows: 405 | - view requirement 406 | - edit requirement 407 | - add requirement 408 | - delete requirement 409 | - reload requirements 410 | 411 | Uses: 412 | - doorstop as backend 413 | - table view 414 | ''' 415 | 416 | def __init__(self, docId=None, parent=None): 417 | super(RequirementManager, self).__init__(parent) 418 | self._docId = docId 419 | self.load() 420 | 421 | def load(self): 422 | self.loadModel() # fills in the table 423 | self.loadDelegate() # delegate is necessary to edit the "text" field 424 | self.loadView() # table view 425 | 426 | def loadModel(self): 427 | self.model = RequirementSetModel(self._docId) 428 | 429 | def loadDelegate(self): 430 | self.delegate = RequirementsDelegate() 431 | 432 | def loadView(self): 433 | # Table 434 | self.view = QTableView() 435 | self.view.setModel(self.model) 436 | self.view.setItemDelegate(self.delegate) 437 | self.view.setContextMenuPolicy(Qt.CustomContextMenu) 438 | self.view.customContextMenuRequested.connect(self.onCustomContextMenuRequested) 439 | 440 | # Table appearance 441 | self.view.setMinimumSize(1024, 768) 442 | self.view.hideColumn(self.model._headerData.index('path')) 443 | self.view.hideColumn(self.model._headerData.index('root')) 444 | self.view.hideColumn(self.model._headerData.index('uid')) 445 | self.view.hideColumn(self.model._headerData.index('ref')) 446 | self.view.hideColumn(self.model._headerData.index('references')) 447 | self.view.hideColumn(self.model._headerData.index('links')) 448 | 449 | self.view.horizontalHeader().setStretchLastSection(True) 450 | self.view.setWordWrap(True) 451 | self.view.resizeColumnsToContents() 452 | self.view.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) 453 | self.view.setSelectionMode(QAbstractItemView.SingleSelection) 454 | self.view.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) 455 | self.view.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) # only has effect on the scrollbar dragging 456 | self.view.verticalScrollBar().setSingleStep(15) # mouse wheel scrolling: restricted to 15px per "click" 457 | 458 | # Buttons 459 | reloadBtn = QPushButton("Reload") 460 | reloadBtn.clicked.connect(self.model.load) 461 | # Placement 462 | 463 | ly = QVBoxLayout() 464 | lyBtns = QHBoxLayout() 465 | lyBtns.addWidget(reloadBtn) 466 | lyBtns.addStretch() 467 | ly.addLayout(lyBtns) 468 | ly.addWidget(self.view) 469 | self.setLayout(ly) 470 | 471 | def onCustomContextMenuRequested(self, pos): 472 | menu = QMenu() 473 | idx = self.view.indexAt(pos) 474 | item = self.model.getItem(idx) 475 | if item is not None: 476 | addReqBefore = QAction('Add new requirement before '+str(item)) 477 | addReqBefore.triggered.connect(lambda: self.model.insertRowBefore(idx)) 478 | menu.addAction(addReqBefore) 479 | 480 | addReqAfter = QAction('Add new requirement after '+str(item)) 481 | addReqAfter.triggered.connect(lambda: self.model.insertRowAfter(idx)) 482 | menu.addAction(addReqAfter) 483 | menu.addSeparator() 484 | 485 | if item.get('level').heading == False: 486 | if item.get('normative'): 487 | deactivateReq = QAction('Make '+str(item)+' not normative') 488 | deactivateReq.triggered.connect(lambda: self.model.deactivateRow(idx)) 489 | deactivateReq.setToolTip("Changes the 'normative' attribute to False.\nNon-normative requirements are informative or are not valid on this specific project.") 490 | menu.addAction(deactivateReq) 491 | else: 492 | activateReq = QAction('Make '+str(item)+' normative') 493 | activateReq.triggered.connect(lambda: self.model.activateRow(idx)) 494 | activateReq.setToolTip("Changes the 'normative' attribute to True.\nNormative requirements must be implemented.") 495 | menu.addAction(activateReq) 496 | 497 | if item.get('normative'): 498 | if item.get('derived'): 499 | setNotDerived = QAction('Make '+str(item)+' not derived') 500 | setNotDerived.setToolTip("Changes the 'derived' attribute to False.\nNot derived requirements must have a parent requirement unless they're the top-level requirements.") 501 | setNotDerived.triggered.connect(lambda: self.model.underiveRow(idx)) 502 | menu.addAction(setNotDerived) 503 | else: 504 | setDerived = QAction('Make '+str(item)+' derived') 505 | setDerived.setToolTip("Changes the 'derived' attribute to True.\nDerived requirements don't need to have a parent requirement even if they're not top-level requirements.") 506 | setDerived.triggered.connect(lambda: self.model.deriveRow(idx)) 507 | menu.addAction(setDerived) 508 | 509 | menu.addSeparator() 510 | deleteReq = QAction('Delete '+str(item)+' from disk') 511 | deleteReq.triggered.connect(lambda: self.model.deleteRow(idx)) 512 | menu.addAction(deleteReq) 513 | 514 | menu.exec(self.view.mapToGlobal(pos)) 515 | 516 | # Main application 517 | class MainWindow(QMainWindow): 518 | def __init__(self, parent=None): 519 | super(MainWindow, self).__init__(parent) 520 | self.setWindowTitle('Doorhole - doorstop requirements editor') 521 | 522 | global reqtree 523 | reqtree = doorstop.build() 524 | 525 | self.tabs = QTabWidget() 526 | self.setCentralWidget(self.tabs) 527 | 528 | # One tab for each document 529 | for document in reqtree: 530 | # container widget 531 | container = QTabWidget() 532 | 533 | # widgets 534 | reqsW = QWidget() 535 | reqsView = RequirementManager(document.prefix) 536 | 537 | reqsLy = QVBoxLayout() 538 | reqsLy.addWidget(reqsView) 539 | reqsW.setLayout(reqsLy) 540 | 541 | container.addTab(reqsW, 'Requirements') 542 | 543 | title = document.parent + ' -> ' + document.prefix if document.parent else document.prefix 544 | self.tabs.addTab(container, title) 545 | 546 | if __name__ == "__main__": 547 | app = QApplication(sys.argv) 548 | win = MainWindow() 549 | win.show() 550 | sys.exit(app.exec()) 551 | --------------------------------------------------------------------------------