Mercurial > p > roundup > code
comparison doc/customizing.txt @ 7464:82bbb95e5690 issue2550923_computed_property
merge from tip into issue2550923_computed_property
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Thu, 08 Jun 2023 00:10:32 -0400 |
| parents | ca90f7270cd4 a1d0b505766e |
| children | 14a8e11f3a87 |
comparison
equal
deleted
inserted
replaced
| 7045:ca90f7270cd4 | 7464:82bbb95e5690 |
|---|---|
| 1 .. meta:: | 1 .. meta:: |
| 2 :description: | 2 :description: |
| 3 How to customize and extend the Roundup Issue | 3 How to customize and extend the Roundup Issue |
| 4 Tracker. Includes many cookbook and how-to | 4 Tracker. Includes many cookbook and how-to |
| 5 examples. Reference for the design and internals | 5 examples. |
| 6 needed to understand and extend the examples to meet | |
| 7 new needs. | |
| 8 | 6 |
| 9 :tocdepth: 2 | 7 :tocdepth: 2 |
| 10 | 8 |
| 11 =================== | 9 =================== |
| 12 Customising Roundup | 10 Customising Roundup |
| 13 =================== | 11 =================== |
| 14 | 12 |
| 15 .. This document borrows from the ZopeBook section on ZPT. The original is at: | 13 .. This document borrows from the ZopeBook section on ZPT. The original is at: |
| 16 http://www.zope.org/Documentation/Books/ZopeBook/current/ZPT.stx | 14 http://www.zope.org/Documentation/Books/ZopeBook/current/ZPT.stx |
| 17 | 15 |
| 16 .. admonition:: Welcome | |
| 17 | |
| 18 This document used to be much larger and include a lot of | |
| 19 reference material. That has been moved to the `reference document`_. | |
| 20 | |
| 21 The documentation is slowly being reorganized using the `Diataxis | |
| 22 framework`_. Help with the reorganization is welcome. | |
| 23 | |
| 24 .. _reference document: reference.html | |
| 25 .. _diataxis framework: https://diataxis.fr/ | |
| 26 | |
| 18 .. contents:: | 27 .. contents:: |
| 19 :depth: 2 | 28 :depth: 3 |
| 20 :local: | 29 :local: |
| 21 | 30 |
| 22 What You Can Do | 31 What You Can Do |
| 23 =============== | 32 =============== |
| 24 | 33 |
| 25 Before you get too far, it's probably worth having a quick read of the Roundup | 34 Before you get too far, it's probably worth having a quick read of the Roundup |
| 26 `design documentation`_. | 35 `design documentation`_. |
| 27 | 36 |
| 28 Customisation of Roundup can take one of six forms: | 37 Customisation of Roundup can take one of six forms: |
| 29 | 38 |
| 30 1. `tracker configuration`_ changes | 39 1. `tracker configuration <reference.html#tracker-configuration>`_ changes |
| 31 2. database, or `tracker schema`_ changes | 40 2. database, or `tracker schema <reference.html#tracker-schema>`_ changes |
| 32 3. "definition" class `database content`_ changes | 41 3. "definition" class `database content <reference.html#database-content>`_ changes |
| 33 4. behavioural changes through detectors_, extensions_ and interfaces.py_ | 42 4. behavioural changes through `detectors <reference.html#detectors>`_, |
| 34 5. `security / access controls`_ | 43 `extensions <reference.html#extensions>`_ and |
| 35 6. change the `web interface`_ | 44 `interfaces.py <reference.html#interfaces-py>`_ |
| 45 5. `security / access controls <reference.html#security-access-controls>`_ | |
| 46 6. change the `web interface <reference.html#web-interface>`_ | |
| 36 | 47 |
| 37 The third case is special because it takes two distinctly different forms | 48 The third case is special because it takes two distinctly different forms |
| 38 depending upon whether the tracker has been initialised or not. The other two | 49 depending upon whether the tracker has been initialised or not. The other two |
| 39 may be done at any time, before or after tracker initialisation. Yes, this | 50 may be done at any time, before or after tracker initialisation. Yes, this |
| 40 includes adding or removing properties from classes. | 51 includes adding or removing properties from classes. |
| 41 | 52 |
| 42 | 53 .. _CustomExamples: |
| 43 Trackers in a Nutshell | 54 |
| 44 ====================== | 55 Examples |
| 45 | 56 ======== |
| 46 Trackers have the following structure: | 57 |
| 47 | 58 Changing what's stored in the database |
| 48 .. index:: | 59 -------------------------------------- |
| 49 single: tracker; structure db directory | 60 |
| 50 single: tracker; structure detectors directory | 61 The following examples illustrate ways to change the information stored in |
| 51 single: tracker; structure extensions directory | 62 the database. |
| 52 single: tracker; structure html directory | 63 |
| 53 single: tracker; structure html directory | 64 |
| 54 single: tracker; structure lib directory | 65 Adding a new field to the classic schema |
| 55 | 66 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 56 =================== ======================================================== | 67 |
| 57 Tracker File Description | 68 This example shows how to add a simple field (a due date) to the default |
| 58 =================== ======================================================== | 69 classic schema. It does not add any additional behaviour, such as enforcing |
| 59 config.ini Holds the basic `tracker configuration`_ | 70 the due date, or causing automatic actions to fire if the due date passes. |
| 60 schema.py Holds the `tracker schema`_ | 71 |
| 61 initial_data.py Holds any data to be entered into the database when the | 72 You add new fields by editing the ``schema.py`` file in you tracker's home. |
| 62 tracker is initialised. | 73 Schema changes are automatically applied to the database on the next |
| 63 db/ Holds the tracker's database | 74 tracker access (note that roundup-server would need to be restarted as it |
| 64 db/files/ Holds the tracker's upload files and messages | 75 caches the schema). |
| 65 db/backend_name Names the database back-end for the tracker (obsolete). | 76 |
| 66 Current way uses the ``backend`` setting in the rdbms | 77 .. index:: schema; example changes |
| 67 section of config.ini. | 78 |
| 68 detectors/ Auditors and reactors for this tracker | 79 1. Modify the ``schema.py``:: |
| 69 extensions/ Additional actions and `templating utilities`_ | 80 |
| 70 html/ Web interface templates, images and style sheets | 81 issue = IssueClass(db, "issue", |
| 71 lib/ optional common imports for detectors and extensions | 82 assignedto=Link("user"), keyword=Multilink("keyword"), |
| 72 =================== ======================================================== | 83 priority=Link("priority"), status=Link("status"), |
| 73 | 84 due_date=Date()) |
| 74 | 85 |
| 75 .. index:: config.ini | 86 2. Add an edit field to the ``issue.item.html`` template:: |
| 76 .. index:: configuration; see config.ini | 87 |
| 77 | 88 <tr> |
| 78 Tracker Configuration | 89 <th>Due Date</th> |
| 79 ===================== | 90 <td tal:content="structure context/due_date/field" /> |
| 80 | 91 </tr> |
| 81 The ``config.ini`` located in your tracker home contains the basic | 92 |
| 82 configuration for the web and e-mail components of roundup's interfaces. | 93 If you want to show only the date part of due_date then do this instead:: |
| 83 | |
| 84 Changes to the data captured by your tracker is controlled by the `tracker | |
| 85 schema`_. Some configuration is also performed using permissions - see the | |
| 86 `security / access controls`_ section. For example, to allow users to | |
| 87 automatically register through the email interface, you must grant the | |
| 88 "Anonymous" Role the "Email Access" Permission. | |
| 89 | |
| 90 .. index:: | |
| 91 single: config.ini; sections | |
| 92 see: configuration; config.ini | |
| 93 | |
| 94 The following is taken from the `Python Library Reference`__ (July 18, 2018) | |
| 95 section "ConfigParser -- Configuration file parser": | |
| 96 | |
| 97 The configuration file consists of sections, led by a [section] header | |
| 98 and followed by name: value entries, with continuations in the style | |
| 99 of RFC 822 (see section 3.1.1, “LONG HEADER FIELDS”); name=value is | |
| 100 also accepted. Note that leading whitespace is removed from | |
| 101 values. The optional values can contain format strings which refer to | |
| 102 other values in the same section, or values in a special DEFAULT | |
| 103 section. Additional defaults can be provided on initialization and | |
| 104 retrieval. Lines beginning with '#' or ';' are ignored and may be | |
| 105 used to provide comments. | |
| 106 | |
| 107 For example:: | |
| 108 | |
| 109 [My Section] | |
| 110 foodir = %(dir)s/whatever | |
| 111 dir = frob | |
| 112 | |
| 113 would resolve the "%(dir)s" to the value of "dir" ("frob" in this case) | |
| 114 resulting in "foodir" being "frob/whatever". | |
| 115 | |
| 116 __ https://docs.python.org/2/library/configparser.html | |
| 117 | |
| 118 Example configuration settings are below. This is a partial | |
| 119 list. Documentation on all the settings is included in the | |
| 120 ``config.ini`` file. | |
| 121 | |
| 122 .. index:: config.ini; sections main | |
| 123 | |
| 124 Section **main** | |
| 125 database -- ``db`` | |
| 126 Database directory path. The path may be either absolute or relative | |
| 127 to the directory containig this config file. | |
| 128 | |
| 129 templates -- ``html`` | |
| 130 Path to the HTML templates directory. The path may be either absolute | |
| 131 or relative to the directory containing this config file. | |
| 132 | |
| 133 static_files -- default *blank* | |
| 134 A list of space separated directory paths (or a single directory). | |
| 135 These directories hold additional static files available via Web UI. | |
| 136 These directories may contain sitewide images, CSS stylesheets etc. If | |
| 137 a '-' is included, the list processing ends and the TEMPLATES | |
| 138 directory is not searched after the specified directories. If this | |
| 139 option is not set, all static files are taken from the TEMPLATES | |
| 140 directory. | |
| 141 | |
| 142 admin_email -- ``roundup-admin`` | |
| 143 Email address that roundup will complain to if it runs into trouble. If | |
| 144 the email address doesn't contain an ``@`` part, the MAIL_DOMAIN defined | |
| 145 below is used. | |
| 146 | |
| 147 dispatcher_email -- ``roundup-admin`` | |
| 148 The 'dispatcher' is a role that can get notified of new items to the | |
| 149 database. It is used by the ERROR_MESSAGES_TO config setting. If the | |
| 150 email address doesn't contain an ``@`` part, the MAIL_DOMAIN defined | |
| 151 below is used. | |
| 152 | |
| 153 email_from_tag -- default *blank* | |
| 154 Additional text to include in the "name" part of the From: address used | |
| 155 in nosy messages. If the sending user is "Foo Bar", the From: line | |
| 156 is usually: ``"Foo Bar" <issue_tracker@tracker.example>`` | |
| 157 the EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so: | |
| 158 ``"Foo Bar EMAIL_FROM_TAG" <issue_tracker@tracker.example>`` | |
| 159 | |
| 160 new_web_user_roles -- ``User`` | |
| 161 Roles that a user gets when they register with Web User Interface. | |
| 162 This is a comma-separated list of role names (e.g. ``Admin,User``). | |
| 163 | |
| 164 new_email_user_roles -- ``User`` | |
| 165 Roles that a user gets when they register with Email Gateway. | |
| 166 This is a comma-separated string of role names (e.g. ``Admin,User``). | |
| 167 | |
| 168 error_messages_to -- ``user`` | |
| 169 Send error message emails to the ``dispatcher``, ``user``, or ``both``? | |
| 170 The dispatcher is configured using the DISPATCHER_EMAIL setting. | |
| 171 Allowed values: ``dispatcher``, ``user``, or ``both`` | |
| 172 | |
| 173 html_version -- ``html4`` | |
| 174 HTML version to generate. The templates are ``html4`` by default. | |
| 175 If you wish to make them xhtml, then you'll need to change this | |
| 176 var to ``xhtml`` too so all auto-generated HTML is compliant. | |
| 177 Allowed values: ``html4``, ``xhtml`` | |
| 178 | |
| 179 timezone -- ``0`` | |
| 180 Numeric timezone offset used when users do not choose their own | |
| 181 in their settings. | |
| 182 | |
| 183 instant_registration -- ``yes`` | |
| 184 Register new users instantly, or require confirmation via | |
| 185 email? | |
| 186 Allowed values: ``yes``, ``no`` | |
| 187 | |
| 188 email_registration_confirmation -- ``yes`` | |
| 189 Offer registration confirmation by email or only through the web? | |
| 190 Allowed values: ``yes``, ``no`` | |
| 191 | |
| 192 indexer_stopwords -- default *blank* | |
| 193 Additional stop-words for the full-text indexer specific to | |
| 194 your tracker. See the indexer source for the default list of | |
| 195 stop-words (e.g. ``A,AND,ARE,AS,AT,BE,BUT,BY, ...``). | |
| 196 | |
| 197 umask -- ``02`` | |
| 198 Defines the file creation mode mask. | |
| 199 | |
| 200 csv_field_size -- ``131072`` | |
| 201 Maximum size of a csv-field during import. Roundups export | |
| 202 format is a csv (comma separated values) variant. The csv | |
| 203 reader has a limit on the size of individual fields | |
| 204 starting with python 2.5. Set this to a higher value if you | |
| 205 get the error 'Error: field larger than field limit' during | |
| 206 import. | |
| 207 | |
| 208 .. index:: config.ini; sections tracker | |
| 209 | |
| 210 Section **tracker** | |
| 211 name -- ``Roundup issue tracker`` | |
| 212 A descriptive name for your roundup instance. | |
| 213 | |
| 214 web -- ``http://host.example/demo/`` | |
| 215 The web address that the tracker is viewable at. | |
| 216 This will be included in information sent to users of the tracker. | |
| 217 The URL MUST include the cgi-bin part or anything else | |
| 218 that is required to get to the home page of the tracker. | |
| 219 You MUST include a trailing '/' in the URL. | |
| 220 | |
| 221 email -- ``issue_tracker`` | |
| 222 Email address that mail to roundup should go to. | |
| 223 | |
| 224 language -- default *blank* | |
| 225 Default locale name for this tracker. If this option is not set, the | |
| 226 language is determined by the environment variable LANGUAGE, LC_ALL, | |
| 227 LC_MESSAGES, or LANG, in that order of preference. | |
| 228 | |
| 229 .. index:: config.ini; sections web | |
| 230 | |
| 231 Section **web** | |
| 232 allow_html_file -- ``no`` | |
| 233 Setting this option enables Roundup to serve uploaded HTML | |
| 234 file content *as HTML*. This is a potential security risk | |
| 235 and is therefore disabled by default. Set to 'yes' if you | |
| 236 trust *all* users uploading content to your tracker. | |
| 237 | |
| 238 http_auth -- ``yes`` | |
| 239 Whether to use HTTP Basic Authentication, if present. | |
| 240 Roundup will use either the REMOTE_USER or HTTP_AUTHORIZATION | |
| 241 variables supplied by your web server (in that order). | |
| 242 Set this option to 'no' if you do not wish to use HTTP Basic | |
| 243 Authentication in your web interface. | |
| 244 | |
| 245 use_browser_language -- ``yes`` | |
| 246 Whether to use HTTP Accept-Language, if present. | |
| 247 Browsers send a language-region preference list. | |
| 248 It's usually set in the client's browser or in their | |
| 249 Operating System. | |
| 250 Set this option to 'no' if you want to ignore it. | |
| 251 | |
| 252 debug -- ``no`` | |
| 253 Setting this option makes Roundup display error tracebacks | |
| 254 in the user's browser rather than emailing them to the | |
| 255 tracker admin."), | |
| 256 | |
| 257 .. index:: config.ini; sections rdbms | |
| 258 single: config.ini; database settings | |
| 259 | |
| 260 Section **rdbms** | |
| 261 Settings in this section are used to set the backend and configure | |
| 262 addition settings needed by RDBMs like SQLite, Postgresql and | |
| 263 MySQL backends. | |
| 264 | |
| 265 .. index:: | |
| 266 single: postgres; select backend in config.ini | |
| 267 single: mysql; select backend in config.ini | |
| 268 single: sqlite; select backend in config.ini | |
| 269 single: anydbm; select backend in config.ini | |
| 270 see: database; postgres | |
| 271 see: database; mysql | |
| 272 see: database; sqlite | |
| 273 see: database; anydbm | |
| 274 | |
| 275 backend -- set to value by init | |
| 276 The database backend such as anydbm, sqlite, mysql or postgres. | |
| 277 | |
| 278 name -- ``roundup`` | |
| 279 Name of the database to use. | |
| 280 | |
| 281 host -- ``localhost`` | |
| 282 Database server host. | |
| 283 | |
| 284 port -- default *blank* | |
| 285 TCP port number of the database server. Postgresql usually resides on | |
| 286 port 5432 (if any), for MySQL default port number is 3306. Leave this | |
| 287 option empty to use backend default. | |
| 288 | |
| 289 user -- ``roundup`` | |
| 290 Database user name that Roundup should use. | |
| 291 | |
| 292 password -- ``roundup`` | |
| 293 Database user password. | |
| 294 | |
| 295 read_default_file -- ``~/.my.cnf`` | |
| 296 Name of the MySQL defaults file. Only used in MySQL connections. | |
| 297 | |
| 298 read_default_group -- ``roundup`` | |
| 299 Name of the group to use in the MySQL defaults file. Only used in | |
| 300 MySQL connections. | |
| 301 | |
| 302 .. index:: | |
| 303 single: sqlite; lock timeout | |
| 304 | |
| 305 sqlite_timeout -- ``30`` | |
| 306 Number of seconds to wait when the SQLite database is locked. | |
| 307 Used only for SQLite. | |
| 308 | |
| 309 cache_size -- `100` | |
| 310 Size of the node cache (in elements) used to keep most recently used | |
| 311 data in memory. | |
| 312 | |
| 313 .. index:: config.ini; sections logging | |
| 314 see: logging; config.ini, sections logging | |
| 315 | |
| 316 Section **logging** | |
| 317 config -- default *blank* | |
| 318 Path to configuration file for standard Python logging module. If this | |
| 319 option is set, logging configuration is loaded from specified file; | |
| 320 options 'filename' and 'level' in this section are ignored. The path may | |
| 321 be either absolute or relative to the directory containig this config file. | |
| 322 | |
| 323 filename -- default *blank* | |
| 324 Log file name for minimal logging facility built into Roundup. If no file | |
| 325 name specified, log messages are written on stderr. If above 'config' | |
| 326 option is set, this option has no effect. The path may be either absolute | |
| 327 or relative to the directory containig this config file. | |
| 328 | |
| 329 level -- ``ERROR`` | |
| 330 Minimal severity level of messages written to log file. If above 'config' | |
| 331 option is set, this option has no effect. | |
| 332 Allowed values: ``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` | |
| 333 | |
| 334 .. index:: config.ini; sections mail | |
| 335 | |
| 336 Section **mail** | |
| 337 Outgoing email options. Used for nosy messages, password reset and | |
| 338 registration approval requests. | |
| 339 | |
| 340 domain -- ``localhost`` | |
| 341 Domain name used for email addresses. | |
| 342 | |
| 343 host -- default *blank* | |
| 344 SMTP mail host that roundup will use to send mail | |
| 345 | |
| 346 username -- default *blank* | |
| 347 SMTP login name. Set this if your mail host requires authenticated access. | |
| 348 If username is not empty, password (below) MUST be set! | |
| 349 | |
| 350 password -- default *blank* | |
| 351 SMTP login password. | |
| 352 Set this if your mail host requires authenticated access. | |
| 353 | |
| 354 port -- default *25* | |
| 355 SMTP port on mail host. | |
| 356 Set this if your mail host runs on a different port. | |
| 357 | |
| 358 local_hostname -- default *blank* | |
| 359 The fully qualified domain name (FQDN) to use during SMTP sessions. If left | |
| 360 blank, the underlying SMTP library will attempt to detect your FQDN. If your | |
| 361 mail host requires something specific, specify the FQDN to use. | |
| 362 | |
| 363 tls -- ``no`` | |
| 364 If your SMTP mail host provides or requires TLS (Transport Layer Security) | |
| 365 then you may set this option to 'yes'. | |
| 366 Allowed values: ``yes``, ``no`` | |
| 367 | |
| 368 tls_keyfile -- default *blank* | |
| 369 If TLS is used, you may set this option to the name of a PEM formatted | |
| 370 file that contains your private key. The path may be either absolute or | |
| 371 relative to the directory containig this config file. | |
| 372 | |
| 373 tls_certfile -- default *blank* | |
| 374 If TLS is used, you may set this option to the name of a PEM formatted | |
| 375 certificate chain file. The path may be either absolute or relative | |
| 376 to the directory containig this config file. | |
| 377 | |
| 378 charset -- utf-8 | |
| 379 Character set to encode email headers with. We use utf-8 by default, as | |
| 380 it's the most flexible. Some mail readers (eg. Eudora) can't cope with | |
| 381 that, so you might need to specify a more limited character set | |
| 382 (eg. iso-8859-1). | |
| 383 | |
| 384 debug -- default *blank* | |
| 385 Setting this option makes Roundup to write all outgoing email messages | |
| 386 to this file *instead* of sending them. This option has the same effect | |
| 387 as environment variable SENDMAILDEBUG. Environment variable takes | |
| 388 precedence. The path may be either absolute or relative to the directory | |
| 389 containig this config file. | |
| 390 | |
| 391 add_authorinfo -- ``yes`` | |
| 392 Add a line with author information at top of all messages send by | |
| 393 roundup. | |
| 394 | |
| 395 add_authoremail -- ``yes`` | |
| 396 Add the mail address of the author to the author information at the | |
| 397 top of all messages. If this is false but add_authorinfo is true, | |
| 398 only the name of the actor is added which protects the mail address | |
| 399 of the actor from being exposed at mail archives, etc. | |
| 400 | |
| 401 .. index:: config.ini; sections mailgw | |
| 402 single: mailgw; config | |
| 403 see: mail gateway; mailgw | |
| 404 | |
| 405 Section **mailgw** | |
| 406 Roundup Mail Gateway options | |
| 407 | |
| 408 keep_quoted_text -- ``yes`` | |
| 409 Keep email citations when accepting messages. Setting this to ``no`` strips | |
| 410 out "quoted" text from the message. Signatures are also stripped. | |
| 411 Allowed values: ``yes``, ``no`` | |
| 412 | |
| 413 leave_body_unchanged -- ``no`` | |
| 414 Preserve the email body as is - that is, keep the citations *and* | |
| 415 signatures. | |
| 416 Allowed values: ``yes``, ``no`` | |
| 417 | |
| 418 default_class -- ``issue`` | |
| 419 Default class to use in the mailgw if one isn't supplied in email subjects. | |
| 420 To disable, leave the value blank. | |
| 421 | |
| 422 language -- default *blank* | |
| 423 Default locale name for the tracker mail gateway. If this option is | |
| 424 not set, mail gateway will use the language of the tracker instance. | |
| 425 | |
| 426 subject_prefix_parsing -- ``strict`` | |
| 427 Controls the parsing of the [prefix] on subject lines in incoming emails. | |
| 428 ``strict`` will return an error to the sender if the [prefix] is not | |
| 429 recognised. ``loose`` will attempt to parse the [prefix] but just | |
| 430 pass it through as part of the issue title if not recognised. ``none`` | |
| 431 will always pass any [prefix] through as part of the issue title. | |
| 432 | |
| 433 subject_suffix_parsing -- ``strict`` | |
| 434 Controls the parsing of the [suffix] on subject lines in incoming emails. | |
| 435 ``strict`` will return an error to the sender if the [suffix] is not | |
| 436 recognised. ``loose`` will attempt to parse the [suffix] but just | |
| 437 pass it through as part of the issue title if not recognised. ``none`` | |
| 438 will always pass any [suffix] through as part of the issue title. | |
| 439 | |
| 440 subject_suffix_delimiters -- ``[]`` | |
| 441 Defines the brackets used for delimiting the commands suffix in a subject | |
| 442 line. | |
| 443 | |
| 444 subject_content_match -- ``always`` | |
| 445 Controls matching of the incoming email subject line against issue titles | |
| 446 in the case where there is no designator [prefix]. ``never`` turns off | |
| 447 matching. ``creation + interval`` or ``activity + interval`` will match | |
| 448 an issue for the interval after the issue's creation or last activity. | |
| 449 The interval is a standard Roundup interval. | |
| 450 | |
| 451 subject_updates_title -- ``yes`` | |
| 452 Update issue title if incoming subject of email is different. | |
| 453 Setting this to ``no`` will ignore the title part of | |
| 454 the subject of incoming email messages. | |
| 455 | |
| 456 refwd_re -- ``(\s*\W?\s*(fw|fwd|re|aw|sv|ang)\W)+`` | |
| 457 Regular expression matching a single reply or forward prefix | |
| 458 prepended by the mailer. This is explicitly stripped from the | |
| 459 subject during parsing. Value is Python Regular Expression | |
| 460 (UTF8-encoded). | |
| 461 | |
| 462 origmsg_re -- `` ^[>|\s]*-----\s?Original Message\s?-----$`` | |
| 463 Regular expression matching start of an original message if quoted | |
| 464 in the body. Value is Python Regular Expression (UTF8-encoded). | |
| 465 | |
| 466 sign_re -- ``^[>|\s]*-- ?$`` | |
| 467 Regular expression matching the start of a signature in the message | |
| 468 body. Value is Python Regular Expression (UTF8-encoded). | |
| 469 | |
| 470 eol_re -- ``[\r\n]+`` | |
| 471 Regular expression matching end of line. Value is Python Regular | |
| 472 Expression (UTF8-encoded). | |
| 473 | |
| 474 blankline_re -- ``[\r\n]+\s*[\r\n]+`` | |
| 475 Regular expression matching a blank line. Value is Python Regular | |
| 476 Expression (UTF8-encoded). | |
| 477 | |
| 478 ignore_alternatives -- ``no`` | |
| 479 When parsing incoming mails, roundup uses the first | |
| 480 text/plain part it finds. If this part is inside a | |
| 481 multipart/alternative, and this option is set, all other | |
| 482 parts of the multipart/alternative are ignored. The default | |
| 483 is to keep all parts and attach them to the issue. | |
| 484 | |
| 485 .. index:: config.ini; sections php | |
| 486 | |
| 487 Section **pgp** | |
| 488 OpenPGP mail processing options | |
| 489 | |
| 490 enable -- ``no`` | |
| 491 Enable PGP processing. Requires gpg. | |
| 492 | |
| 493 roles -- default *blank* | |
| 494 If specified, a comma-separated list of roles to perform PGP | |
| 495 processing on. If not specified, it happens for all users. | |
| 496 | |
| 497 homedir -- default *blank* | |
| 498 Location of PGP directory. Defaults to $HOME/.gnupg if not | |
| 499 specified. | |
| 500 | |
| 501 | |
| 502 .. index:: config.ini; sections nosy | |
| 503 | |
| 504 Section **nosy** | |
| 505 Nosy messages sending | |
| 506 | |
| 507 messages_to_author -- ``no`` | |
| 508 Send nosy messages to the author of the message. | |
| 509 If ``yes`` is used, then messages are sent to the author | |
| 510 even if not on the nosy list, same for ``new`` (but only for new messages). | |
| 511 When set to ``nosy``, the nosy list controls sending messages to the author. | |
| 512 Allowed values: ``yes``, ``no``, ``new``, ``nosy`` | |
| 513 | |
| 514 signature_position -- ``bottom`` | |
| 515 Where to place the email signature. | |
| 516 Allowed values: ``top``, ``bottom``, ``none`` | |
| 517 | |
| 518 add_author -- ``new`` | |
| 519 Does the author of a message get placed on the nosy list automatically? | |
| 520 If ``new`` is used, then the author will only be added when a message | |
| 521 creates a new issue. If ``yes``, then the author will be added on | |
| 522 followups too. If ``no``, they're never added to the nosy. | |
| 523 Allowed values: ``yes``, ``no``, ``new`` | |
| 524 | |
| 525 add_recipients -- ``new`` | |
| 526 Do the recipients (``To:``, ``Cc:``) of a message get placed on the nosy | |
| 527 list? If ``new`` is used, then the recipients will only be added when a | |
| 528 message creates a new issue. If ``yes``, then the recipients will be added | |
| 529 on followups too. If ``no``, they're never added to the nosy. | |
| 530 Allowed values: ``yes``, ``no``, ``new`` | |
| 531 | |
| 532 email_sending -- ``single`` | |
| 533 Controls the email sending from the nosy reactor. If ``multiple`` then | |
| 534 a separate email is sent to each recipient. If ``single`` then a single | |
| 535 email is sent with each recipient as a CC address. | |
| 536 | |
| 537 max_attachment_size -- ``2147483647`` | |
| 538 Attachments larger than the given number of bytes won't be attached | |
| 539 to nosy mails. They will be replaced by a link to the tracker's | |
| 540 download page for the file. | |
| 541 | |
| 542 | |
| 543 .. index:: single: roundup-admin; config.ini update | |
| 544 single: roundup-admin; config.ini create | |
| 545 single: config.ini; create | |
| 546 single: config.ini; update | |
| 547 | |
| 548 You may generate a new default config file using the ``roundup-admin | |
| 549 genconfig`` command. You can generate a new config file merging in | |
| 550 existing settings using the ``roundup-admin updateconfig`` command. | |
| 551 | |
| 552 Configuration variables may be referred to in lower or upper case. In code, | |
| 553 variables not in the "main" section are referred to using their section and | |
| 554 name, so "domain" in the section "mail" becomes MAIL_DOMAIN. | |
| 555 | |
| 556 .. index:: pair: configuration; extensions | |
| 557 pair: configuration; detectors | |
| 558 | |
| 559 Extending the configuration file | |
| 560 -------------------------------- | |
| 561 | |
| 562 You can't add new variables to the config.ini file in the tracker home but | |
| 563 you can add two new config.ini files: | |
| 564 | |
| 565 - a config.ini in the ``extensions`` directory will be loaded and attached | |
| 566 to the config variable as "ext". | |
| 567 - a config.ini in the ``detectors`` directory will be loaded and attached | |
| 568 to the config variable as "detectors". | |
| 569 | |
| 570 For example, the following in ``detectors/config.ini``:: | |
| 571 | |
| 572 [main] | |
| 573 qa_recipients = email@example.com | |
| 574 | |
| 575 is accessible as:: | |
| 576 | |
| 577 db.config.detectors['QA_RECIPIENTS'] | |
| 578 | |
| 579 Note that the name grouping applied to the main configuration file is | |
| 580 applied to the extension config files, so if you instead have:: | |
| 581 | |
| 582 [qa] | |
| 583 recipients = email@example.com | |
| 584 | |
| 585 then the above ``db.config.detectors['QA_RECIPIENTS']`` will still work. | |
| 586 | |
| 587 Unlike values in the tracker's main ``config.ini``, the values defined | |
| 588 in these config files are not validated. For example: a setting that | |
| 589 is supposed to be an integer value (e.g. 4) could be the word | |
| 590 "foo". If you are writing Python code that uses these settings, you | |
| 591 should expect to handle invalid values. | |
| 592 | |
| 593 Also, incorrect values aren't discovered until the config setting is | |
| 594 used. This can be long after the tracker is started and the error may | |
| 595 not be seen in the logs. | |
| 596 | |
| 597 It is possible to validate these settings. Validation involves calling | |
| 598 the ``update_options`` method on the configuration option. This can be | |
| 599 done from the ``init()`` function in the Python files implementing | |
| 600 extensions_ or detectors_. | |
| 601 | |
| 602 As an example, adding the following to an extension:: | |
| 603 | |
| 604 from roundup.configuration import SecretMandatoryOption | |
| 605 | |
| 606 def init(instance): | |
| 607 instance.config.ext.update_option('RECAPTCHA_SECRET', | |
| 608 SecretMandatoryOption,description="Secret securing reCaptcha.") | |
| 609 | |
| 610 similarly for a detector:: | |
| 611 | |
| 612 from roundup.configuration import MailAddressOption | |
| 613 | |
| 614 def init(db): | |
| 615 try: | |
| 616 db.config.detectors.update_option('QA_RECIPIENTS', | |
| 617 MailAddressOption, | |
| 618 description="Email used for QA comment followup.") | |
| 619 except KeyError: | |
| 620 # COMMENT_EMAIL setting is not found, but it's optional | |
| 621 # so continue | |
| 622 pass | |
| 623 | |
| 624 will allow reading the secret from a file or append the tracker domain | |
| 625 to an email address if it does not have a domain. | |
| 626 | |
| 627 Running ``roundup-admin -i tracker_home display user1`` will validate | |
| 628 the settings for both config.ini`s. Otherwise detector options are not | |
| 629 validated until the first request to the web interface (or email | |
| 630 gateway). | |
| 631 | |
| 632 There are 4 arguments for ``update_option``: | |
| 633 | |
| 634 1. config setting name - string (positional, mandatory) | |
| 635 2. option type - Option derived class from configuration.py | |
| 636 (positional, mandatory) | |
| 637 3. default value - string (optional, named default) | |
| 638 4. description - string (optional, named description) | |
| 639 | |
| 640 The first argument is the config setting name as described at the | |
| 641 beginning of this section. | |
| 642 | |
| 643 The second argument is a class in the roundup.configuration module. | |
| 644 There are a number of these classes: BooleanOption, | |
| 645 IntegerNumberOption, RegExpOption.... Please see the configuration | |
| 646 module for all Option validators and their descriptions. You can also | |
| 647 define your own custom validator in `interfaces.py`_. | |
| 648 | |
| 649 The third and fourth arguments are strings and are optional. They are | |
| 650 printed if there is an error and may help the user correct the problem. | |
| 651 | |
| 652 .. index:: ! schema | |
| 653 | |
| 654 Tracker Schema | |
| 655 ============== | |
| 656 | |
| 657 .. note:: | |
| 658 if you modify the schema, you'll most likely need to edit the | |
| 659 `web interface`_ HTML template files and `detectors`_ to reflect | |
| 660 your changes. | |
| 661 | |
| 662 A tracker schema defines what data is stored in the tracker's database. | |
| 663 Schemas are defined using Python code in the ``schema.py`` module of your | |
| 664 tracker. | |
| 665 | |
| 666 The ``schema.py`` and ``initial_data.py`` modules | |
| 667 ------------------------------------------------- | |
| 668 | |
| 669 The schema.py module is used to define what your tracker looks like | |
| 670 on the inside, the schema of the tracker. It defines the Classes | |
| 671 and properties on each class. It also defines the security for | |
| 672 those Classes. The next few sections describe how schemas work | |
| 673 and what you can do with them. | |
| 674 | |
| 675 The initial_data.py module sets up the initial state of your | |
| 676 tracker. It’s called exactly once - by the ``roundup-admin initialise`` | |
| 677 command. See the start of the section on database content for more | |
| 678 info about how this works. | |
| 679 | |
| 680 .. index:: schema; classic - description of | |
| 681 | |
| 682 The "classic" schema | |
| 683 -------------------- | |
| 684 | |
| 685 The "classic" schema looks like this (see section `setkey(property)`_ | |
| 686 below for the meaning of ``'setkey'`` -- you may also want to look into | |
| 687 the sections `setlabelprop(property)`_ and `setorderprop(property)`_ for | |
| 688 specifying (default) labelling and ordering of classes.):: | |
| 689 | |
| 690 pri = Class(db, "priority", name=String(), order=String()) | |
| 691 pri.setkey("name") | |
| 692 | |
| 693 stat = Class(db, "status", name=String(), order=String()) | |
| 694 stat.setkey("name") | |
| 695 | |
| 696 keyword = Class(db, "keyword", name=String()) | |
| 697 keyword.setkey("name") | |
| 698 | |
| 699 user = Class(db, "user", username=String(), organisation=String(), | |
| 700 password=String(), address=String(), realname=String(), | |
| 701 phone=String(), alternate_addresses=String(), | |
| 702 queries=Multilink('query'), roles=String(), timezone=String()) | |
| 703 user.setkey("username") | |
| 704 | |
| 705 msg = FileClass(db, "msg", author=Link("user"), summary=String(), | |
| 706 date=Date(), recipients=Multilink("user"), | |
| 707 files=Multilink("file"), messageid=String(), inreplyto=String()) | |
| 708 | |
| 709 file = FileClass(db, "file", name=String()) | |
| 710 | |
| 711 issue = IssueClass(db, "issue", keyword=Multilink("keyword"), | |
| 712 status=Link("status"), assignedto=Link("user"), | |
| 713 priority=Link("priority")) | |
| 714 issue.setkey('title') | |
| 715 | |
| 716 .. index:: schema; allowed changes | |
| 717 | |
| 718 What you can't do to the schema | |
| 719 ------------------------------- | |
| 720 | |
| 721 You must never: | |
| 722 | |
| 723 **Remove the users class** | |
| 724 This class is the only *required* class in Roundup. | |
| 725 | |
| 726 **Remove the "username", "address", "password" or "realname" user properties** | |
| 727 Various parts of Roundup require these properties. Don't remove them. | |
| 728 | |
| 729 **Change the type of a property** | |
| 730 Property types must *never* be changed - the database simply doesn't take | |
| 731 this kind of action into account. Note that you can't just remove a | |
| 732 property and re-add it as a new type either. If you wanted to make the | |
| 733 assignedto property a Multilink, you'd need to create a new property | |
| 734 assignedto_list and remove the old assignedto property. | |
| 735 | |
| 736 | |
| 737 What you can do to the schema | |
| 738 ----------------------------- | |
| 739 | |
| 740 Your schema may be changed at any time before or after the tracker has been | |
| 741 initialised (or used). You may: | |
| 742 | |
| 743 **Add new properties to classes, or add whole new classes** | |
| 744 This is painless and easy to do - there are generally no repercussions | |
| 745 from adding new information to a tracker's schema. | |
| 746 | |
| 747 **Remove properties** | |
| 748 Removing properties is a little more tricky - you need to make sure that | |
| 749 the property is no longer used in the `web interface`_ *or* by the | |
| 750 detectors_. | |
| 751 | |
| 752 | |
| 753 | |
| 754 Classes and Properties - creating a new information store | |
| 755 --------------------------------------------------------- | |
| 756 | |
| 757 In the tracker above, we've defined 7 classes of information: | |
| 758 | |
| 759 priority | |
| 760 Defines the possible levels of urgency for issues. | |
| 761 | |
| 762 status | |
| 763 Defines the possible states of processing the issue may be in. | |
| 764 | |
| 765 keyword | |
| 766 Initially empty, will hold keywords useful for searching issues. | |
| 767 | |
| 768 user | |
| 769 Initially holding the "admin" user, will eventually have an entry | |
| 770 for all users using roundup. | |
| 771 | |
| 772 msg | |
| 773 Initially empty, will hold all e-mail messages sent to or | |
| 774 generated by roundup. | |
| 775 | |
| 776 file | |
| 777 Initially empty, will hold all files attached to issues. | |
| 778 | |
| 779 issue | |
| 780 Initially empty, this is where the issue information is stored. | |
| 781 | |
| 782 We define the "priority" and "status" classes to allow two things: | |
| 783 | |
| 784 1. reduction in the amount of information stored on the issue | |
| 785 2. more powerful, accurate searching of issues by priority and status | |
| 786 | |
| 787 By only requiring a link on the issue (which is stored as a single | |
| 788 number) we reduce the chance that someone mis-types a priority or | |
| 789 status - or simply makes a new one up. | |
| 790 | |
| 791 | |
| 792 Class and Items | |
| 793 ~~~~~~~~~~~~~~~ | |
| 794 | |
| 795 A Class defines a particular class (or type) of data that will be stored | |
| 796 in the database. A class comprises one or more properties, which gives | |
| 797 the information about the class items. | |
| 798 | |
| 799 The actual data entered into the database, using ``class.create()``, are | |
| 800 called items. They have a special immutable property called ``'id'``. We | |
| 801 sometimes refer to this as the *itemid*. | |
| 802 | |
| 803 | |
| 804 .. index:: schema; property types | |
| 805 | |
| 806 Properties | |
| 807 ~~~~~~~~~~ | |
| 808 | |
| 809 A Class is comprised of one or more properties of the following types: | |
| 810 | |
| 811 String | |
| 812 properties are for storing arbitrary-length strings. | |
| 813 Password | |
| 814 properties are for storing encoded arbitrary-length strings. | |
| 815 The default encoding is defined on the ``roundup.password.Password`` | |
| 816 class. | |
| 817 Computed | |
| 818 properties invoke a python function. The return value of the | |
| 819 function is the value of the property. Unlike other properties, | |
| 820 the property is read only and can not be changed. Use cases: | |
| 821 ask a remote interface for a value (e.g. retrieve user's office | |
| 822 location from ldap, query state of a related ticket from | |
| 823 another roundup instance). It can be used to compute a value | |
| 824 (e.g. count the number of messages for an issue). The type | |
| 825 returned by the function is the type of the value. (Note it is | |
| 826 coerced to a string when displayed in the html interface.) At | |
| 827 this time it's a partial implementation. It can be | |
| 828 displayed/queried only. It can not be searched or used for | |
| 829 sorting or grouping as it does not exist in the back end | |
| 830 database. | |
| 831 Date | |
| 832 properties store date-and-time stamps. Their values are Timestamp | |
| 833 objects. | |
| 834 Interval | |
| 835 properties store time periods rather than absolute dates. For | |
| 836 example 2 hours. | |
| 837 Integer | |
| 838 properties store integer values. (Number can store real/float values.) | |
| 839 Number | |
| 840 properties store numeric values. There is an option to use | |
| 841 double-precision floating point numbers. | |
| 842 Boolean | |
| 843 properties store on/off, yes/no, true/false values. | |
| 844 Link | |
| 845 properties refers to a single other item selected from a | |
| 846 specified class. The class is part of the property; the value is an | |
| 847 integer, the id of the chosen item. | |
| 848 Multilink | |
| 849 properties refer to possibly many items in a specified | |
| 850 class. The value is a list of integers. | |
| 851 | |
| 852 Properties can have additional attributes to change the default | |
| 853 behaviour: | |
| 854 | |
| 855 .. index:: triple: schema; property attributes; required | |
| 856 triple: schema; property attributes; default_value | |
| 857 triple: schema; property attributes; quiet | |
| 858 | |
| 859 * All properties support the following attributes: | |
| 860 | |
| 861 - ``required``: see `design documentation`_. Adds the property to | |
| 862 the list returned by calling get_required_props for the class. | |
| 863 - ``default_value``: see `design documentation`_ Sets the default | |
| 864 value if the property is not set. | |
| 865 - ``quiet``: see `design documentation`_. Suppresses user visible | |
| 866 to changes to this property. The property change is not reported: | |
| 867 | |
| 868 - in the change feedback/confirmation message in the web | |
| 869 interface | |
| 870 - the property change section of the nosy email | |
| 871 - the web history at the bottom of an item's page | |
| 872 | |
| 873 This can be used to store state of the user interface (e.g. the | |
| 874 names of elements that are collapsed or hidden from the | |
| 875 user). Making properties that are updated as an indirect result of | |
| 876 a user's change (e.g. updating a blockers property, counting | |
| 877 number of times an issue was reopened or reassigned etc.) should | |
| 878 not be displayed to the user as they can be confusing. | |
| 879 | |
| 880 .. index:: triple: schema; property attributes; indexme | |
| 881 | |
| 882 * String properties can have an ``indexme`` attribute that defines if the | |
| 883 property should be part of the full text index. The default is 'no' but this | |
| 884 can be set to 'yes' to allow a property's contents to be in the full | |
| 885 text index. | |
| 886 | |
| 887 .. index:: triple: schema; property attributes; use_double | |
| 888 | |
| 889 * Number properties can have a ``use_double`` attribute that, when set | |
| 890 to ``True``, will use double precision floating point in the database. | |
| 891 * Link and Multilink properties can have several attributes: | |
| 892 | |
| 893 .. index:: triple: schema; property attributes; do_journal | |
| 894 | |
| 895 - ``do_journal``: By default, every change of a link property is | |
| 896 recorded in the item being linked to (or being unlinked). A typical | |
| 897 use-case for setting ``do_journal='no'`` would be to turn off | |
| 898 journalling of nosy list, message author and message recipient link | |
| 899 and unlink events to prevent the journal from clogged with these | |
| 900 events. | |
| 901 | |
| 902 .. index:: triple: schema; property attributes; try_id_parsing | |
| 903 | |
| 904 - ``try_id_parsing`` is turned on by default. If entering a number | |
| 905 into a Link or Multilink field, roundup interprets this number as an | |
| 906 ID of the item to link to. Sometimes items can have numeric names | |
| 907 (like, e.g., product codes). For these roundup needs to match the | |
| 908 numeric name and should never match an ID. In this case you can set | |
| 909 ``try_id_parsing='no'``. | |
| 910 | |
| 911 .. index:: triple: schema; property attributes; rev_multilink | |
| 912 | |
| 913 - The ``rev_multilink`` option takes a property name to be inserted | |
| 914 into the linked-to class. This property is a Multilink property that | |
| 915 links back to the current class. The new Multilink is read-only (it | |
| 916 is automatically modified if the Link or Multilink property defining | |
| 917 it is modified). The new property can be used in normal searches | |
| 918 using the "filter" method of the Class. This means it can be used | |
| 919 like other Multilink properties when searching (in an index | |
| 920 template) or via the REST and XMLRPC APIs. | |
| 921 | |
| 922 As a example, suppose you want to group multiple issues into a | |
| 923 super issue. Each issue can be part of only one super issue. It is | |
| 924 inefficient to find all of the issues that are part of the | |
| 925 super issue by searching through all issues in the system looking | |
| 926 at the part_of link property. To make this more efficient, you | |
| 927 can declare an issue's part_of property as:: | |
| 928 | |
| 929 issue = IssueClass(db, "issue", | |
| 930 ... | |
| 931 part_of = Link("issue", rev_multilink="components"), | |
| 932 ... ) | |
| 933 | |
| 934 This automatically creates the ``components`` multilink on the issue | |
| 935 class. The ``components`` multilink is never explicitly declared in | |
| 936 the issue class, but it has the same effect as though you had | |
| 937 declared the class as:: | |
| 938 | |
| 939 issue = IssueClass(db, "issue", | |
| 940 ... | |
| 941 part_of = Link("issue"), | |
| 942 components = Multilink("issue"), | |
| 943 ... ) | |
| 944 | |
| 945 Then wrote a detector to update the components property on the | |
| 946 corresponding issue. Writing this detector can be tricky. There is | |
| 947 one other difference, you can not explicitly set/modify the | |
| 948 ``components`` multilink. | |
| 949 | |
| 950 The effect of setting ``part_of = 3456`` on issue1234 | |
| 951 automatically adds "1234" to the ``components`` property on | |
| 952 issue3456. You can search the ``components`` multilink just like a | |
| 953 regular multilink, but you can't explicitly assign to it. | |
| 954 Another difference of reverse multilinks to normal multilinks | |
| 955 is that when a linked node is retired, the node vanishes from the | |
| 956 multilink, e.g. in the example above, if an issue with ``part_of`` | |
| 957 set to another issue is retired this issue vanishes from the | |
| 958 ``components`` multilink of the other issue. | |
| 959 | |
| 960 You can also link between different classes. So you can modify | |
| 961 the issue definition to include:: | |
| 962 | |
| 963 issue = IssueClass(db, "issue", | |
| 964 ... | |
| 965 assigned_to = Link("user", rev_multilink="responsibleFor"), | |
| 966 ... ) | |
| 967 | 94 |
| 968 This makes it easy to list all issues that the user is responsible | 95 <tr> |
| 969 for (aka assigned_to). | 96 <th>Due Date</th> |
| 970 | 97 <td tal:content="structure python:context.due_date.field(format='%Y-%m-%d')" /> |
| 971 .. index:: triple: schema; property attributes; msg_header_property | 98 </tr> |
| 972 | 99 |
| 973 - The ``msg_header_property`` is used by the mail gateway when sending | 100 3. Add the property to the ``issue.index.html`` page:: |
| 974 out messages. When a link or multilink property of an issue changes, | 101 |
| 975 roundup creates email headers of the form:: | 102 (in the heading row) |
| 976 | 103 <th tal:condition="request/show/due_date">Due Date</th> |
| 977 X-Roundup-issue-prop: value | 104 (in the data row) |
| 978 | 105 <td tal:condition="request/show/due_date" |
| 979 where ``value`` is the ``name`` property for the linked item(s). | 106 tal:content="i/due_date" /> |
| 980 For example, if you have a multilink for attached_files in your | 107 |
| 981 issue, you will see a header:: | 108 If you want format control of the display of the due date you can |
| 982 | 109 enter the following in the data row to show only the actual due date:: |
| 983 X-Roundup-issue-attached_files: MySpecialFile.doc, HisResume.txt | 110 |
| 984 | 111 <td tal:condition="request/show/due_date" |
| 985 when the class for attached files is defined as:: | 112 tal:content="python:i.due_date.pretty('%Y-%m-%d')"> </td> |
| 986 | 113 |
| 987 file = FileClass(db, "file", name=String()) | 114 4. Add the property to the ``issue.search.html`` page:: |
| 988 | 115 |
| 989 ``MySpecialFile.doc`` is the name for the file object. | 116 <tr tal:define="name string:due_date"> |
| 990 | 117 <th i18n:translate="">Due Date:</th> |
| 991 If you have an ``assigned_to`` property in your issue class that | 118 <td metal:use-macro="search_input"></td> |
| 992 links to the user class and you want to add a header:: | 119 <td metal:use-macro="column_input"></td> |
| 993 | 120 <td metal:use-macro="sort_input"></td> |
| 994 X-Roundup-issue-assigned_to: ... | 121 <td metal:use-macro="group_input"></td> |
| 995 | 122 </tr> |
| 996 so that the mail recipients can filter emails where | 123 |
| 997 ``X-Roundup-issue-assigned_to: name`` that contains their | 124 5. If you wish for the due date to appear in the standard views listed |
| 998 username. The user class is defined as:: | 125 in the sidebar of the web interface then you'll need to add "due_date" |
| 999 | 126 to the columns and columns_showall lists in your ``page.html``:: |
| 1000 user = Class(db, "user", | 127 |
| 128 columns string:id,activity,due_date,title,creator,status; | |
| 129 columns_showall string:id,activity,due_date,title,creator,assignedto,status; | |
| 130 | |
| 131 Adding a new Computed field to the schema | |
| 132 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 133 | |
| 134 Computed properties are a bit different from other properties. They do | |
| 135 not actually change the database. Computed fields are not contained in | |
| 136 the database and can not be searched or used for sorting or | |
| 137 grouping. (Patches to add this capability are welcome.) | |
| 138 | |
| 139 In this example we will add a count of the number of files attached to | |
| 140 the issue. This could be done using an auditor (see below) to update | |
| 141 an integer field called ``filecount``. But we will implement this in a | |
| 142 different way. | |
| 143 | |
| 144 We have two changes to make: | |
| 145 | |
| 146 1. add a new python method to the hyperdb.Computed class. It will | |
| 147 count the number of files attached to the issue. This method will | |
| 148 be added in the (possibly new) interfaces.py file in the top level | |
| 149 of the tracker directory. (See interfaces.py above for more | |
| 150 information.) | |
| 151 2. add a new ``filecount`` property to the issue class calling the | |
| 152 new function. | |
| 153 | |
| 154 A Computed method receives three arguments when called: | |
| 155 | |
| 156 1. the computed object (self) | |
| 157 2. the id of the item in the class | |
| 158 3. the database object | |
| 159 | |
| 160 To add the method to the Computed class, modify the trackers | |
| 161 interfaces.py adding:: | |
| 162 | |
| 163 import roundup.hyperdb as hyperdb | |
| 164 def filecount(self, nodeid, db): | |
| 165 return len(db.issue.get(nodeid, 'files')) | |
| 166 setattr(hyperdb.Computed, 'filecount', filecount) | |
| 167 | |
| 168 Then add:: | |
| 169 | |
| 170 filecount = Computed(Computed.filecount), | |
| 171 | |
| 172 to the existing IssueClass call. | |
| 173 | |
| 174 Now you can retrieve the value of the ``filecount`` property and it | |
| 175 will be computed on the fly from the existing list of attached files. | |
| 176 | |
| 177 This example was done with the IssueClass, but you could add a | |
| 178 Computed property to any class. E.G.:: | |
| 179 | |
| 180 user = Class(db, "user", | |
| 1001 username=String(), | 181 username=String(), |
| 1002 password=Password(), | 182 password=Password(), |
| 1003 address=String(), | 183 address=String(), |
| 1004 realname=String(), | 184 realname=String(), |
| 1005 phone=String(), | 185 phone=String(), |
| 186 office=Computed(Computed.getOfficeFromLdap), # new prop | |
| 1006 organisation=String(), | 187 organisation=String(), |
| 1007 alternate_addresses=String(), | 188 alternate_addresses=String(), |
| 1008 queries=Multilink('query'), | 189 queries=Multilink('query'), |
| 1009 roles=String(), # comma-separated string of Role names | 190 roles=String(), |
| 1010 timezone=String()) | 191 timezone=String()) |
| 1011 | 192 |
| 1012 Because there is no ``name`` parameter for the user class, there | 193 where the method ``getOfficeFromLdap`` queries the local ldap server to |
| 1013 will be no header. However setting:: | 194 get the current office location information. The method will be called |
| 1014 | 195 with the Computed instance, the user id and the database object. |
| 1015 assigned_to=Link("user", msg_header_property="username") | 196 |
| 1016 | 197 Adding a new constrained field to the classic schema |
| 1017 will make the mail gateway generate an ``X-Roundup-issue-assigned_to`` | 198 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 1018 using the username property of the linked user. | 199 |
| 1019 | 200 This example shows how to add a new constrained property (i.e. a |
| 1020 Assume assigned_to for an issue is linked to the user with | 201 selection of distinct values) to your tracker. |
| 1021 username=joe_user, setting:: | 202 |
| 1022 | 203 |
| 1023 msg_header_property="username" | 204 Introduction |
| 1024 | 205 :::::::::::: |
| 1025 for the assigned_to property will generated message headers of the | 206 |
| 1026 form:: | 207 To make the classic schema of Roundup useful as a TODO tracking system |
| 1027 | 208 for a group of systems administrators, it needs an extra data field per |
| 1028 X-Roundup-issue-assigned_to: joe_user | 209 issue: a category. |
| 1029 | 210 |
| 1030 for emails sent on issues where joe_user has been assigned to the issue. | 211 This would let sysadmins quickly list all TODOs in their particular area |
| 1031 | 212 of interest without having to do complex queries, and without relying on |
| 1032 If this property is set to the empty string "", it will prevent | 213 the spelling capabilities of other sysadmins (a losing proposition at |
| 1033 the header from being generated on outgoing mail. | 214 best). |
| 1034 | 215 |
| 1035 .. index:: triple: schema; class property; creator | 216 |
| 1036 triple: schema; class property; creation | 217 Adding a field to the database |
| 1037 triple: schema; class property; actor | 218 :::::::::::::::::::::::::::::: |
| 1038 triple: schema; class property; activity | 219 |
| 1039 | 220 This is the easiest part of the change. The category would just be a |
| 1040 All Classes automatically have a number of properties by default: | 221 plain string, nothing fancy. To change what is in the database you need |
| 1041 | 222 to add some lines to the ``schema.py`` file of your tracker instance. |
| 1042 *creator* | 223 Under the comment:: |
| 1043 Link to the user that created the item. | 224 |
| 1044 *creation* | 225 # add any additional database schema configuration here |
| 1045 Date the item was created. | 226 |
| 1046 *actor* | 227 add:: |
| 1047 Link to the user that last modified the item. | 228 |
| 1048 *activity* | 229 category = Class(db, "category", name=String()) |
| 1049 Date the item was last modified. | 230 category.setkey("name") |
| 1050 | 231 |
| 1051 | 232 Here we are setting up a chunk of the database which we are calling |
| 1052 .. index:: triple: schema; class property; content | 233 "category". It contains a string, which we are refering to as "name" for |
| 1053 triple: schema; class property; type | 234 lack of a more imaginative title. (Since "name" is one of the properties |
| 1054 | 235 that Roundup looks for on items if you do not set a key for them, it's |
| 1055 FileClass | 236 probably a good idea to stick with it for new classes if at all |
| 1056 ~~~~~~~~~ | 237 appropriate.) Then we are setting the key of this chunk of the database |
| 1057 | 238 to be that "name". This is equivalent to an index for database types. |
| 1058 FileClasses save their "content" attribute off in a separate file from | 239 This also means that there can only be one category with a given name. |
| 1059 the rest of the database. This reduces the number of large entries in | 240 |
| 1060 the database, which generally makes databases more efficient, and also | 241 Adding the above lines allows us to create categories, but they're not |
| 1061 allows us to use command-line tools to operate on the files. They are | 242 tied to the issues that we are going to be creating. It's just a list of |
| 1062 stored in the files sub-directory of the ``'db'`` directory in your | 243 categories off on its own, which isn't much use. We need to link it in |
| 1063 tracker. FileClasses also have a "type" attribute to store the MIME | 244 with the issues. To do that, find the lines |
| 1064 type of the file. | 245 in ``schema.py`` which set up the "issue" class, and then add a link to |
| 1065 | 246 the category:: |
| 1066 Roundup by default considers the contents of the file immutable. This | 247 |
| 1067 is to assist in maintaining an accurate record of correspondence. The | 248 issue = IssueClass(db, "issue", ... , |
| 1068 distributed tracker templates do not enforce this. So if you have | 249 category=Multilink("category"), ... ) |
| 1069 access to the roundup tracker directory, you can edit the files (make | 250 |
| 1070 sure to preserve mode, owner and group) to remove information (e.g. if | 251 The ``Multilink()`` means that each issue can have many categories. If |
| 1071 somebody includes a password or you need to redact proprietary | 252 you were adding something with a one-to-one relationship to issues (such |
| 1072 information). Obviously the journal for the message/file will not | 253 as the "assignedto" property), use ``Link()`` instead. |
| 1073 report that the file has changed. | 254 |
| 1074 | 255 That is all you need to do to change the schema. The rest of the effort |
| 1075 Best practice is to remove offending material and leave a | 256 is fiddling around so you can actually use the new category. |
| 1076 placeholder. E.G. replace a password with the text:: | 257 |
| 1077 | 258 |
| 1078 [password has been deleted 2020-12-02 --myname] | 259 Populating the new category class |
| 1079 | 260 ::::::::::::::::::::::::::::::::: |
| 1080 If you need to delete an entire file, replace the file contents with:: | 261 |
| 1081 | 262 If you haven't initialised the database with the |
| 1082 [file contents deleted due to spam 202-10-21 --myname] | 263 "``roundup-admin initialise``" command, then you |
| 1083 | 264 can add the following to the tracker ``initial_data.py`` |
| 1084 rather than deleting the file. If you actually delete the file roundup | 265 under the comment:: |
| 1085 will report an error to the user and email the administrator. If you | 266 |
| 1086 empty the file, a user downloading the file using the direct URL | 267 # add any additional database creation steps here - but only if you |
| 1087 (e.g. ``tracker/msg22``) may be confused and think something is broken | 268 # haven't initialised the database with the admin "initialise" command |
| 1088 when they receive an empty file. Retiring a file/msg does not prevent | 269 |
| 1089 access to the file using the direct URL. Retiring an item only removes | 270 Add:: |
| 1090 it when requesting a list of all items in the class. If you are | 271 |
| 1091 replacing the contents, you probably want to change the content type | 272 category = db.getclass('category') |
| 1092 of the file. E.G. from ``image/jpeg`` to ``text/plain``. You can do | 273 category.create(name="scipy") |
| 1093 this easily through the web interface, or using the ``roundup-admin`` | 274 category.create(name="chaco") |
| 1094 command line interface. | 275 category.create(name="weave") |
| 1095 | 276 |
| 1096 You can also change the contents of a file or message using the REST | 277 .. index:: roundup-admin; create entries in class |
| 1097 interface. Note that this will NOT result in an entry in the journal, | 278 |
| 1098 so again it allows a silent change. To do this you need to make two | 279 If the database has already been initalised, then you need to use the |
| 1099 rest requests. An example using curl is:: | 280 ``roundup-admin`` tool:: |
| 1100 | 281 |
| 1101 $ curl -u demo:demo -s | 282 % roundup-admin -i <tracker home> |
| 1102 -H "X-requested-with: rest" \ | 283 Roundup <version> ready for input. |
| 1103 -H "Referer: https://tracker.example.com/demo/" \ | 284 Type "help" for help. |
| 1104 -X GET \ | 285 roundup> create category name=scipy |
| 1105 https://tracker.example.com/demo/rest/data/file/30/content | 286 1 |
| 1106 { | 287 roundup> create category name=chaco |
| 1107 "data": { | 288 2 |
| 1108 "id": "30", | 289 roundup> create category name=weave |
| 1109 "type": "<class 'str'>", | 290 3 |
| 1110 "link": "https://tracker.example.com/demo/rest/data/file/30/conten | 291 roundup> exit... |
| 1111 t", | 292 There are unsaved changes. Commit them (y/N)? y |
| 1112 "data": "hello3", | 293 |
| 1113 "@etag": "\"3f2f8063dbce5b6bd43567e6f4f3c671\"" | 294 |
| 1114 } | 295 Setting up security on the new objects |
| 1115 } | 296 :::::::::::::::::::::::::::::::::::::: |
| 1116 | 297 |
| 1117 using the etag, overwrite the content with:: | 298 By default only the admin user can look at and change objects. This |
| 1118 | 299 doesn't suit us, as we want any user to be able to create new categories |
| 1119 $ curl -u demo:demo -s | 300 as required, and obviously everyone needs to be able to view the |
| 1120 -H "X-requested-with: rest" \ | 301 categories of issues for it to be useful. |
| 1121 -H "Referer: https://tracker.example.com/demo/" \ | 302 |
| 1122 -H 'If-Match: "3f2f8063dbce5b6bd43567e6f4f3c671"' \ | 303 We therefore need to change the security of the category objects. This |
| 1123 -X PUT \ | 304 is also done in ``schema.py``. |
| 1124 -F "data=@hello" \ | 305 |
| 1125 https://tracker.example.com/demo/rest/data/file/30/content | 306 There are currently two loops which set up permissions and then assign |
| 1126 | 307 them to various roles. Simply add the new "category" to both lists:: |
| 1127 where ``hello`` is a file on local disk. | 308 |
| 1128 | 309 # Assign the access and edit permissions for issue, file and message |
| 1129 You can enforce immutability in your tracker by adding an auditor (see | 310 # to regular users now |
| 1130 detectors_) for the file/msg class that rejects changes to the content | 311 for cl in 'issue', 'file', 'msg', 'category': |
| 1131 property. The auditor could also add a journal entry so that a change | 312 p = db.security.getPermission('View', cl) |
| 1132 via the roundup mechanism is reported. Using a mixin (see: | 313 db.security.addPermissionToRole('User', 'View', cl) |
| 1133 https://wiki.roundup-tracker.org/MixinClassFileClass) to augment the | 314 db.security.addPermissionToRole('User', 'Edit', cl) |
| 1134 file class allows for other possibilities including signing the file, or | 315 db.security.addPermissionToRole('User', 'Create', cl) |
| 1135 recording a checksum in the database and validating the file contents | 316 |
| 1136 at the time it gets read. This allows detection of changes done on the | 317 These lines assign the "View" and "Edit" Permissions to the "User" role, |
| 1137 filesystem outside of the roundup mechanism. | 318 so that normal users can view and edit "category" objects. |
| 1138 | 319 |
| 1139 .. index:: triple: schema; class property; messages | 320 This is all the work that needs to be done for the database. It will |
| 1140 triple: schema; class property; files | 321 store categories, and let users view and edit them. Now on to the |
| 1141 triple: schema; class property; nosy | 322 interface stuff. |
| 1142 triple: schema; class property; superseder | 323 |
| 1143 | 324 |
| 1144 IssueClass | 325 Changing the web left hand frame |
| 1145 ~~~~~~~~~~ | 326 :::::::::::::::::::::::::::::::: |
| 1146 | 327 |
| 1147 IssueClasses automatically include the "messages", "files", "nosy", and | 328 We need to give the users the ability to create new categories, and the |
| 1148 "superseder" properties. | 329 place to put the link to this functionality is in the left hand function |
| 1149 | 330 bar, under the "Issues" area. The file that defines how this area looks |
| 1150 The messages and files properties list the links to the messages and | 331 is ``html/page.html``, which is what we are going to be editing next. |
| 1151 files related to the issue. The nosy property is a list of links to | 332 |
| 1152 users who wish to be informed of changes to the issue - they get "CC'ed" | 333 If you look at this file you can see that it contains a lot of |
| 1153 e-mails when messages are sent to or generated by the issue. The nosy | 334 "classblock" sections which are chunks of HTML that will be included or |
| 1154 reactor (in the ``'detectors'`` directory) handles this action. The | 335 excluded in the output depending on whether the condition in the |
| 1155 superseder link indicates an issue which has superseded this one. | 336 classblock is met. We are going to add the category code at the end of |
| 1156 | 337 the classblock for the *issue* class:: |
| 1157 They also have the dynamically generated "creation", "activity" and | 338 |
| 1158 "creator" properties. | 339 <p class="classblock" |
| 1159 | 340 tal:condition="python:request.user.hasPermission('View', 'category')"> |
| 1160 The value of the "creation" property is the date when an item was | 341 <b>Categories</b><br> |
| 1161 created, and the value of the "activity" property is the date when any | 342 <a tal:condition="python:request.user.hasPermission('Edit', 'category')" |
| 1162 property on the item was last edited (equivalently, these are the dates | 343 href="category?@template=item">New Category<br></a> |
| 1163 on the first and last records in the item's journal). The "creator" | 344 </p> |
| 1164 property holds a link to the user that created the issue. | 345 |
| 1165 | 346 The first two lines is the classblock definition, which sets up a |
| 1166 .. index: triple: schema; class method; setkey | 347 condition that only users who have "View" permission for the "category" |
| 1167 | 348 object will have this section included in their output. Next comes a |
| 1168 setkey(property) | 349 plain "Categories" header in bold. Everyone who can view categories will |
| 1169 ~~~~~~~~~~~~~~~~ | 350 get that. |
| 1170 | 351 |
| 1171 .. index:: roundup-admin; setting assignedto on an issue | 352 Next comes the link to the editing area of categories. This link will |
| 1172 | 353 only appear if the condition - that the user has "Edit" permissions for |
| 1173 Select a String property of the class to be the key property. The key | 354 the "category" objects - is matched. If they do have permission then |
| 1174 property must be unique, and allows references to the items in the class | 355 they will get a link to another page which will let the user add new |
| 1175 by the content of the key property. That is, we can refer to users by | 356 categories. |
| 1176 their username: for example, let's say that there's an issue in roundup, | 357 |
| 1177 issue 23. There's also a user, richard, who happens to be user 2. To | 358 Note that if you have permission to *view* but not to *edit* categories, |
| 1178 assign an issue to him, we could do either of:: | 359 then all you will see is a "Categories" header with nothing underneath |
| 1179 | 360 it. This is obviously not very good interface design, but will do for |
| 1180 roundup-admin set issue23 assignedto=2 | 361 now. I just claim that it is so I can add more links in this section |
| 1181 | 362 later on. However, to fix the problem you could change the condition in |
| 1182 or:: | 363 the classblock statement, so that only users with "Edit" permission |
| 1183 | 364 would see the "Categories" stuff. |
| 1184 roundup-admin set issue23 assignedto=richard | 365 |
| 1185 | 366 |
| 1186 Note, the same thing can be done in the web and e-mail interfaces. | 367 Setting up a page to edit categories |
| 1187 | 368 :::::::::::::::::::::::::::::::::::: |
| 1188 .. index: triple: schema; class method; setlabelprop | 369 |
| 1189 | 370 We defined code in the previous section which let users with the |
| 1190 setlabelprop(property) | 371 appropriate permissions see a link to a page which would let them edit |
| 1191 ~~~~~~~~~~~~~~~~~~~~~~ | 372 conditions. Now we have to write that page. |
| 1192 | 373 |
| 1193 Select a property of the class to be the label property. The label | 374 The link was for the *item* template of the *category* object. This |
| 1194 property is used whereever an item should be uniquely identified, e.g., | 375 translates into Roundup looking for a file called ``category.item.html`` |
| 1195 when displaying a link to an item. If setlabelprop is not specified for | 376 in the ``html`` tracker directory. This is the file that we are going to |
| 1196 a class, the following values are tried for the label: | 377 write now. |
| 1197 | 378 |
| 1198 * the key of the class (see the `setkey(property)`_ section above) | 379 First, we add an info tag in a comment which doesn't affect the outcome |
| 1199 * the "name" property | 380 of the code at all, but is useful for debugging. If you load a page in a |
| 1200 * the "title" property | 381 browser and look at the page source, you can see which sections come |
| 1201 * the first property from the sorted property name list | 382 from which files by looking for these comments:: |
| 1202 | 383 |
| 1203 So in most cases you can get away without specifying setlabelprop | 384 <!-- category.item --> |
| 1204 explicitly. | 385 |
| 1205 | 386 Next we need to add in the METAL macro stuff so we get the normal page |
| 1206 You should make sure that users have View access to this property or | 387 trappings:: |
| 1207 the id property for a class. If the property can not be viewed by a | 388 |
| 1208 user, looping over items in the class (e.g. messages attached to an | 389 <tal:block metal:use-macro="templates/page/macros/icing"> |
| 1209 issue) will not work. | 390 <title metal:fill-slot="head_title">Category editing</title> |
| 1210 | 391 <td class="page-header-top" metal:fill-slot="body_title"> |
| 1211 .. index: triple: schema; class method; setorderprop | 392 <h2>Category editing</h2> |
| 1212 | 393 </td> |
| 1213 setorderprop(property) | 394 <td class="content" metal:fill-slot="content"> |
| 1214 ~~~~~~~~~~~~~~~~~~~~~~ | 395 |
| 1215 | 396 Next we need to setup up a standard HTML form, which is the whole |
| 1216 Select a property of the class to be the order property. The order | 397 purpose of this file. We link to some handy javascript which sends the |
| 1217 property is used whenever using a default sort order for the class, | 398 form through only once. This is to stop users hitting the send button |
| 1218 e.g., when grouping or sorting class A by a link to class B in the user | 399 multiple times when they are impatient and thus having the form sent |
| 1219 interface, the order property of class B is used for sorting. If | 400 multiple times:: |
| 1220 setorderprop is not specified for a class, the following values are tried | 401 |
| 1221 for the order property: | 402 <form method="POST" onSubmit="return submit_once()" |
| 1222 | 403 enctype="multipart/form-data"> |
| 1223 * the property named "order" | 404 |
| 1224 * the label property (see `setlabelprop(property)`_ above) | 405 Next we define some code which sets up the minimum list of fields that |
| 1225 | 406 we require the user to enter. There will be only one field - "name" - so |
| 1226 So in most cases you can get away without specifying setorderprop | 407 they better put something in it, otherwise the whole form is pointless:: |
| 1227 explicitly. | 408 |
| 1228 | 409 <input type="hidden" name="@required" value="name"> |
| 1229 .. index: triple: schema; class method; create | 410 |
| 1230 | 411 To get everything to line up properly we will put everything in a table, |
| 1231 create(information) | 412 and put a nice big header on it so the user has an idea what is |
| 1232 ~~~~~~~~~~~~~~~~~~~ | 413 happening:: |
| 1233 | 414 |
| 1234 Create an item in the database. This is generally used to create items | 415 <table class="form"> |
| 1235 in the "definitional" classes like "priority" and "status". | 416 <tr><th class="header" colspan="2">Category</th></tr> |
| 1236 | 417 |
| 1237 .. index: schema; item ordering | 418 Next, we need the field into which the user is going to enter the new |
| 1238 | 419 category. The ``context.name.field(size=60)`` bit tells Roundup to |
| 1239 A note about ordering | 420 generate a normal HTML field of size 60, and the contents of that field |
| 1240 ~~~~~~~~~~~~~~~~~~~~~ | 421 will be the "name" variable of the current context (namely "category"). |
| 1241 | 422 The upshot of this is that when the user types something in |
| 1242 When we sort items in the hyperdb, we use one of a number of methods, | 423 to the form, a new category will be created with that name:: |
| 1243 depending on the properties being sorted on: | 424 |
| 1244 | 425 <tr> |
| 1245 1. If it's a String, Integer, Number, Date or Interval property, we | 426 <th>Name</th> |
| 1246 just sort the scalar value of the property. Strings are sorted | 427 <td tal:content="structure python:context.name.field(size=60)"> |
| 1247 case-sensitively. | 428 name</td> |
| 1248 2. If it's a Link property, we sort by either the linked item's "order" | 429 </tr> |
| 1249 property (if it has one) or the linked item's "id". | 430 |
| 1250 3. Mulitlinks sort similar to #2, but we start with the first Multilink | 431 Then a submit button so that the user can submit the new category:: |
| 1251 list item, and if they're the same, we sort by the second item, and | 432 |
| 1252 so on. | 433 <tr> |
| 1253 | 434 <td> </td> |
| 1254 Note that if an "order" property is defined on a Class that is used for | 435 <td colspan="3" tal:content="structure context/submit"> |
| 1255 sorting, all items of that Class *must* have a value against the "order" | 436 submit button will go here |
| 1256 property, or sorting will result in random ordering. | 437 </td> |
| 1257 | 438 </tr> |
| 1258 | 439 |
| 1259 Examples of adding to your schema | 440 The ``context/submit`` bit generates the submit button but also |
| 441 generates the @action and @csrf hidden fields. The @action field is | |
| 442 used to tell Roundup how to process the form. The @csrf field provides | |
| 443 a unique single use token to defend against CSRF attacks. (More about | |
| 444 anti-csrf measures can be found in ``upgrading.txt``.) | |
| 445 | |
| 446 Finally we finish off the tags we used at the start to do the METAL | |
| 447 stuff:: | |
| 448 | |
| 449 </td> | |
| 450 </tal:block> | |
| 451 | |
| 452 So putting it all together, and closing the table and form we get:: | |
| 453 | |
| 454 <!-- category.item --> | |
| 455 <tal:block metal:use-macro="templates/page/macros/icing"> | |
| 456 <title metal:fill-slot="head_title">Category editing</title> | |
| 457 <td class="page-header-top" metal:fill-slot="body_title"> | |
| 458 <h2>Category editing</h2> | |
| 459 </td> | |
| 460 <td class="content" metal:fill-slot="content"> | |
| 461 <form method="POST" onSubmit="return submit_once()" | |
| 462 enctype="multipart/form-data"> | |
| 463 | |
| 464 <table class="form"> | |
| 465 <tr><th class="header" colspan="2">Category</th></tr> | |
| 466 | |
| 467 <tr> | |
| 468 <th>Name</th> | |
| 469 <td tal:content="structure python:context.name.field(size=60)"> | |
| 470 name</td> | |
| 471 </tr> | |
| 472 | |
| 473 <tr> | |
| 474 <td> | |
| 475 | |
| 476 <input type="hidden" name="@required" value="name"> | |
| 477 </td> | |
| 478 <td colspan="3" tal:content="structure context/submit"> | |
| 479 submit button will go here | |
| 480 </td> | |
| 481 </tr> | |
| 482 </table> | |
| 483 </form> | |
| 484 </td> | |
| 485 </tal:block> | |
| 486 | |
| 487 This is quite a lot to just ask the user one simple question, but there | |
| 488 is a lot of setup for basically one line (the form line) to do its work. | |
| 489 To add another field to "category" would involve one more line (well, | |
| 490 maybe a few extra to get the formatting correct). | |
| 491 | |
| 492 | |
| 493 Adding the category to the issue | |
| 494 :::::::::::::::::::::::::::::::: | |
| 495 | |
| 496 We now have the ability to create issues to our heart's content, but | |
| 497 that is pointless unless we can assign categories to issues. Just like | |
| 498 the ``html/category.item.html`` file was used to define how to add a new | |
| 499 category, the ``html/issue.item.html`` is used to define how a new issue | |
| 500 is created. | |
| 501 | |
| 502 Just like ``category.issue.html``, this file defines a form which has a | |
| 503 table to lay things out. It doesn't matter where in the table we add new | |
| 504 stuff, it is entirely up to your sense of aesthetics:: | |
| 505 | |
| 506 <th>Category</th> | |
| 507 <td> | |
| 508 <span tal:replace="structure context/category/field" /> | |
| 509 <span tal:replace="structure python:db.category.classhelp('name', | |
| 510 property='category', width='200')" /> | |
| 511 </td> | |
| 512 | |
| 513 First, we define a nice header so that the user knows what the next | |
| 514 section is, then the middle line does what we are most interested in. | |
| 515 This ``context/category/field`` gets replaced by a field which contains | |
| 516 the category in the current context (the current context being the new | |
| 517 issue). | |
| 518 | |
| 519 The classhelp lines generate a link (labelled "list") to a popup window | |
| 520 which contains the list of currently known categories. | |
| 521 | |
| 522 | |
| 523 Searching on categories | |
| 524 ::::::::::::::::::::::: | |
| 525 | |
| 526 Now we can add categories, and create issues with categories. The next | |
| 527 obvious thing that we would like to be able to do, would be to search | |
| 528 for issues based on their category, so that, for example, anyone working | |
| 529 on the web server could look at all issues in the category "Web". | |
| 530 | |
| 531 If you look for "Search Issues" in the ``html/page.html`` file, you will | |
| 532 find that it looks something like | |
| 533 ``<a href="issue?@template=search">Search Issues</a>``. This shows us | |
| 534 that when you click on "Search Issues" it will be looking for a | |
| 535 ``issue.search.html`` file to display. So that is the file that we will | |
| 536 change. | |
| 537 | |
| 538 If you look at this file it should begin to seem familiar, although it | |
| 539 does use some new macros. You can add the new category search code anywhere you | |
| 540 like within that form:: | |
| 541 | |
| 542 <tr tal:define="name string:category; | |
| 543 db_klass string:category; | |
| 544 db_content string:name;"> | |
| 545 <th>Priority:</th> | |
| 546 <td metal:use-macro="search_select"></td> | |
| 547 <td metal:use-macro="column_input"></td> | |
| 548 <td metal:use-macro="sort_input"></td> | |
| 549 <td metal:use-macro="group_input"></td> | |
| 550 </tr> | |
| 551 | |
| 552 The definitions in the ``<tr>`` opening tag are used by the macros: | |
| 553 | |
| 554 - ``search_select`` expands to a drop-down box with all categories using | |
| 555 ``db_klass`` and ``db_content``. | |
| 556 - ``column_input`` expands to a checkbox for selecting what columns | |
| 557 should be displayed. | |
| 558 - ``sort_input`` expands to a radio button for selecting what property | |
| 559 should be sorted on. | |
| 560 - ``group_input`` expands to a radio button for selecting what property | |
| 561 should be grouped on. | |
| 562 | |
| 563 The category search code above would expand to the following:: | |
| 564 | |
| 565 <tr> | |
| 566 <th>Category:</th> | |
| 567 <td> | |
| 568 <select name="category"> | |
| 569 <option value="">don't care</option> | |
| 570 <option value="">------------</option> | |
| 571 <option value="1">scipy</option> | |
| 572 <option value="2">chaco</option> | |
| 573 <option value="3">weave</option> | |
| 574 </select> | |
| 575 </td> | |
| 576 <td><input type="checkbox" name=":columns" value="category"></td> | |
| 577 <td><input type="radio" name=":sort0" value="category"></td> | |
| 578 <td><input type="radio" name=":group0" value="category"></td> | |
| 579 </tr> | |
| 580 | |
| 581 Adding category to the default view | |
| 582 ::::::::::::::::::::::::::::::::::: | |
| 583 | |
| 584 We can now add categories, add issues with categories, and search for | |
| 585 issues based on categories. This is everything that we need to do; | |
| 586 however, there is some more icing that we would like. I think the | |
| 587 category of an issue is important enough that it should be displayed by | |
| 588 default when listing all the issues. | |
| 589 | |
| 590 Unfortunately, this is a bit less obvious than the previous steps. The | |
| 591 code defining how the issues look is in ``html/issue.index.html``. This | |
| 592 is a large table with a form down at the bottom for redisplaying and so | |
| 593 forth. | |
| 594 | |
| 595 Firstly we need to add an appropriate header to the start of the table:: | |
| 596 | |
| 597 <th tal:condition="request/show/category">Category</th> | |
| 598 | |
| 599 The *condition* part of this statement is to avoid displaying the | |
| 600 Category column if the user has selected not to see it. | |
| 601 | |
| 602 The rest of the table is a loop which will go through every issue that | |
| 603 matches the display criteria. The loop variable is "i" - which means | |
| 604 that every issue gets assigned to "i" in turn. | |
| 605 | |
| 606 The new part of code to display the category will look like this:: | |
| 607 | |
| 608 <td tal:condition="request/show/category" | |
| 609 tal:content="i/category"></td> | |
| 610 | |
| 611 The condition is the same as above: only display the condition when the | |
| 612 user hasn't asked for it to be hidden. The next part is to set the | |
| 613 content of the cell to be the category part of "i" - the current issue. | |
| 614 | |
| 615 Finally we have to edit ``html/page.html`` again. This time, we need to | |
| 616 tell it that when the user clicks on "Unassigned Issues" or "All Issues", | |
| 617 the category column should be included in the resulting list. If you | |
| 618 scroll down the page file, you can see the links with lots of options. | |
| 619 The option that we are interested in is the ``:columns=`` one which | |
| 620 tells Roundup which fields of the issue to display. Simply add | |
| 621 "category" to that list and it all should work. | |
| 622 | |
| 623 Adding a time log to your issues | |
| 624 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 625 | |
| 626 We want to log the dates and amount of time spent working on issues, and | |
| 627 be able to give a summary of the total time spent on a particular issue. | |
| 628 | |
| 629 1. Add a new class to your tracker ``schema.py``:: | |
| 630 | |
| 631 # storage for time logging | |
| 632 timelog = Class(db, "timelog", period=Interval()) | |
| 633 | |
| 634 Note that we automatically get the date of the time log entry | |
| 635 creation through the standard property "creation". | |
| 636 | |
| 637 You will need to grant "Creation" permission to the users who are | |
| 638 allowed to add timelog entries. You may do this with:: | |
| 639 | |
| 640 db.security.addPermissionToRole('User', 'Create', 'timelog') | |
| 641 db.security.addPermissionToRole('User', 'View', 'timelog') | |
| 642 | |
| 643 If users are also able to *edit* timelog entries, then also include:: | |
| 644 | |
| 645 db.security.addPermissionToRole('User', 'Edit', 'timelog') | |
| 646 | |
| 647 .. index:: schema; example changes | |
| 648 | |
| 649 2. Link to the new class from your issue class (again, in | |
| 650 ``schema.py``):: | |
| 651 | |
| 652 issue = IssueClass(db, "issue", | |
| 653 assignedto=Link("user"), keyword=Multilink("keyword"), | |
| 654 priority=Link("priority"), status=Link("status"), | |
| 655 times=Multilink("timelog")) | |
| 656 | |
| 657 the "times" property is the new link to the "timelog" class. | |
| 658 | |
| 659 3. We'll need to let people add in times to the issue, so in the web | |
| 660 interface we'll have a new entry field. This is a special field | |
| 661 because unlike the other fields in the ``issue.item`` template, it | |
| 662 affects a different item (a timelog item) and not the template's | |
| 663 item (an issue). We have a special syntax for form fields that affect | |
| 664 items other than the template default item (see the cgi | |
| 665 documentation on `special form variables | |
| 666 <reference.html#special-form-variables>`_). In particular, we add a | |
| 667 field to capture a new timelog item's period:: | |
| 668 | |
| 669 <tr> | |
| 670 <th>Time Log</th> | |
| 671 <td colspan=3><input type="text" name="timelog-1@period" /> | |
| 672 (enter as '3y 1m 4d 2:40:02' or parts thereof) | |
| 673 </td> | |
| 674 </tr> | |
| 675 | |
| 676 and another hidden field that links that new timelog item (new | |
| 677 because it's marked as having id "-1") to the issue item. It looks | |
| 678 like this:: | |
| 679 | |
| 680 <input type="hidden" name="@link@times" value="timelog-1" /> | |
| 681 | |
| 682 On submission, the "-1" timelog item will be created and assigned a | |
| 683 real item id. The "times" property of the issue will have the new id | |
| 684 added to it. | |
| 685 | |
| 686 The full entry will now look like this:: | |
| 687 | |
| 688 <tr> | |
| 689 <th>Time Log</th> | |
| 690 <td colspan=3><input type="text" name="timelog-1@period" /> | |
| 691 (enter as '3y 1m 4d 2:40:02' or parts thereof) | |
| 692 <input type="hidden" name="@link@times" value="timelog-1" /> | |
| 693 </td> | |
| 694 </tr> | |
| 695 | |
| 696 .. _adding-a-time-log-to-your-issues-4: | |
| 697 | |
| 698 4. We want to display a total of the timelog times that have been | |
| 699 accumulated for an issue. To do this, we'll need to actually write | |
| 700 some Python code, since it's beyond the scope of PageTemplates to | |
| 701 perform such calculations. We do this by adding a module ``timespent.py`` | |
| 702 to the ``extensions`` directory in our tracker. The contents of this | |
| 703 file is as follows:: | |
| 704 | |
| 705 from roundup import date | |
| 706 | |
| 707 def totalTimeSpent(times): | |
| 708 ''' Call me with a list of timelog items (which have an | |
| 709 Interval "period" property) | |
| 710 ''' | |
| 711 total = date.Interval('0d') | |
| 712 for time in times: | |
| 713 total += time.period._value | |
| 714 return total | |
| 715 | |
| 716 def init(instance): | |
| 717 instance.registerUtil('totalTimeSpent', totalTimeSpent) | |
| 718 | |
| 719 We will now be able to access the ``totalTimeSpent`` function via the | |
| 720 ``utils`` variable in our templates, as shown in the next step. | |
| 721 | |
| 722 5. Display the timelog for an issue:: | |
| 723 | |
| 724 <table class="otherinfo" tal:condition="context/times"> | |
| 725 <tr><th colspan="3" class="header">Time Log | |
| 726 <tal:block | |
| 727 tal:replace="python:utils.totalTimeSpent(context.times)" /> | |
| 728 </th></tr> | |
| 729 <tr><th>Date</th><th>Period</th><th>Logged By</th></tr> | |
| 730 <tr tal:repeat="time context/times"> | |
| 731 <td tal:content="time/creation"></td> | |
| 732 <td tal:content="time/period"></td> | |
| 733 <td tal:content="time/creator"></td> | |
| 734 </tr> | |
| 735 </table> | |
| 736 | |
| 737 I put this just above the Messages log in my issue display. Note our | |
| 738 use of the ``totalTimeSpent`` method which will total up the times | |
| 739 for the issue and return a new Interval. That will be automatically | |
| 740 displayed in the template as text like "+ 1y 2:40" (1 year, 2 hours | |
| 741 and 40 minutes). | |
| 742 | |
| 743 6. If you're using a persistent web server - ``roundup-server`` or | |
| 744 ``mod_wsgi`` for example - then you'll need to restart that to pick up | |
| 745 the code changes. When that's done, you'll be able to use the new | |
| 746 time logging interface. | |
| 747 | |
| 748 An extension of this modification attaches the timelog entries to any | |
| 749 change message entered at the time of the timelog entry: | |
| 750 | |
| 751 A. Add a link to the timelog to the msg class in ``schema.py``: | |
| 752 | |
| 753 msg = FileClass(db, "msg", | |
| 754 author=Link("user", do_journal='no'), | |
| 755 recipients=Multilink("user", do_journal='no'), | |
| 756 date=Date(), | |
| 757 summary=String(), | |
| 758 files=Multilink("file"), | |
| 759 messageid=String(), | |
| 760 inreplyto=String(), | |
| 761 times=Multilink("timelog")) | |
| 762 | |
| 763 B. Add a new hidden field that links that new timelog item (new | |
| 764 because it's marked as having id "-1") to the new message. | |
| 765 The link is placed in ``issue.item.html`` in the same section that | |
| 766 handles the timelog entry. | |
| 767 | |
| 768 It looks like this after this addition:: | |
| 769 | |
| 770 <tr> | |
| 771 <th>Time Log</th> | |
| 772 <td colspan=3><input type="text" name="timelog-1@period" /> | |
| 773 (enter as '3y 1m 4d 2:40:02' or parts thereof) | |
| 774 <input type="hidden" name="@link@times" value="timelog-1" /> | |
| 775 <input type="hidden" name="msg-1@link@times" value="timelog-1" /> | |
| 776 </td> | |
| 777 </tr> | |
| 778 | |
| 779 The "times" property of the message will have the new id added to it. | |
| 780 | |
| 781 C. Add the timelog listing from step 5. to the ``msg.item.html`` template | |
| 782 so that the timelog entry appears on the message view page. Note that | |
| 783 the call to totalTimeSpent is not used here since there will only be one | |
| 784 single timelog entry for each message. | |
| 785 | |
| 786 I placed it after the Date entry like this:: | |
| 787 | |
| 788 <tr> | |
| 789 <th i18n:translate="">Date:</th> | |
| 790 <td tal:content="context/date"></td> | |
| 791 </tr> | |
| 792 </table> | |
| 793 | |
| 794 <table class="otherinfo" tal:condition="context/times"> | |
| 795 <tr><th colspan="3" class="header">Time Log</th></tr> | |
| 796 <tr><th>Date</th><th>Period</th><th>Logged By</th></tr> | |
| 797 <tr tal:repeat="time context/times"> | |
| 798 <td tal:content="time/creation"></td> | |
| 799 <td tal:content="time/period"></td> | |
| 800 <td tal:content="time/creator"></td> | |
| 801 </tr> | |
| 802 </table> | |
| 803 | |
| 804 <table class="messages"> | |
| 805 | |
| 806 | |
| 807 Tracking different types of issues | |
| 808 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 809 | |
| 810 Sometimes you will want to track different types of issues - developer, | |
| 811 customer support, systems, sales leads, etc. A single Roundup tracker is | |
| 812 able to support multiple types of issues. This example demonstrates adding | |
| 813 a system support issue class to a tracker. | |
| 814 | |
| 815 1. Figure out what information you're going to want to capture. OK, so | |
| 816 this is obvious, but sometimes it's better to actually sit down for a | |
| 817 while and think about the schema you're going to implement. | |
| 818 | |
| 819 2. Add the new issue class to your tracker's ``schema.py``. Just after the | |
| 820 "issue" class definition, add:: | |
| 821 | |
| 822 # list our systems | |
| 823 system = Class(db, "system", name=String(), order=Number()) | |
| 824 system.setkey("name") | |
| 825 | |
| 826 # store issues related to those systems | |
| 827 support = IssueClass(db, "support", | |
| 828 assignedto=Link("user"), keyword=Multilink("keyword"), | |
| 829 status=Link("status"), deadline=Date(), | |
| 830 affects=Multilink("system")) | |
| 831 | |
| 832 3. Copy the existing ``issue.*`` (item, search and index) templates in the | |
| 833 tracker's ``html`` to ``support.*``. Edit them so they use the properties | |
| 834 defined in the ``support`` class. Be sure to check for hidden form | |
| 835 variables like "required" to make sure they have the correct set of | |
| 836 required properties. | |
| 837 | |
| 838 4. Edit the modules in the ``detectors``, adding lines to their ``init`` | |
| 839 functions where appropriate. Look for ``audit`` and ``react`` registrations | |
| 840 on the ``issue`` class, and duplicate them for ``support``. | |
| 841 | |
| 842 5. Create a new sidebar box for the new support class. Duplicate the | |
| 843 existing issues one, changing the ``issue`` class name to ``support``. | |
| 844 | |
| 845 6. Re-start your tracker and start using the new ``support`` class. | |
| 846 | |
| 847 | |
| 848 Optionally, you might want to restrict the users able to access this new | |
| 849 class to just the users with a new "SysAdmin" Role. To do this, we add | |
| 850 some security declarations:: | |
| 851 | |
| 852 db.security.addPermissionToRole('SysAdmin', 'View', 'support') | |
| 853 db.security.addPermissionToRole('SysAdmin', 'Create', 'support') | |
| 854 db.security.addPermissionToRole('SysAdmin', 'Edit', 'support') | |
| 855 | |
| 856 You would then (as an "admin" user) edit the details of the appropriate | |
| 857 users, and add "SysAdmin" to their Roles list. | |
| 858 | |
| 859 Alternatively, you might want to change the Edit/View permissions granted | |
| 860 for the ``issue`` class so that it's only available to users with the "System" | |
| 861 or "Developer" Role, and then the new class you're adding is available to | |
| 862 all with the "User" Role. | |
| 863 | |
| 864 | |
| 865 .. _external-authentication: | |
| 866 | |
| 867 Using External User Databases | |
| 868 ----------------------------- | |
| 869 | |
| 870 Using an external password validation source | |
| 871 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 872 | |
| 873 .. note:: You will need to either have an "admin" user in your external | |
| 874 password source *or* have one of your regular users have | |
| 875 the Admin Role assigned. If you need to assign the Role *after* | |
| 876 making the changes below, you may use the ``roundup-admin`` | |
| 877 program to edit a user's details. | |
| 878 | |
| 879 We have a centrally-managed password changing system for our users. This | |
| 880 results in a UN*X passwd-style file that we use for verification of | |
| 881 users. Entries in the file consist of ``name:password`` where the | |
| 882 password is encrypted using the standard UN*X ``crypt()`` function (see | |
| 883 the ``crypt`` module in your Python distribution). An example entry | |
| 884 would be:: | |
| 885 | |
| 886 admin:aamrgyQfDFSHw | |
| 887 | |
| 888 Each user of Roundup must still have their information stored in the Roundup | |
| 889 database - we just use the passwd file to check their password. To do this, we | |
| 890 need to override the standard ``verifyPassword`` method defined in | |
| 891 ``roundup.cgi.actions.LoginAction`` and register the new class. The | |
| 892 following is added as ``externalpassword.py`` in the tracker ``extensions`` | |
| 893 directory:: | |
| 894 | |
| 895 import os, crypt | |
| 896 from roundup.cgi.actions import LoginAction | |
| 897 | |
| 898 class ExternalPasswordLoginAction(LoginAction): | |
| 899 def verifyPassword(self, userid, password): | |
| 900 '''Look through the file, line by line, looking for a | |
| 901 name that matches. | |
| 902 ''' | |
| 903 # get the user's username | |
| 904 username = self.db.user.get(userid, 'username') | |
| 905 | |
| 906 # the passwords are stored in the "passwd.txt" file in the | |
| 907 # tracker home | |
| 908 file = os.path.join(self.db.config.TRACKER_HOME, 'passwd.txt') | |
| 909 | |
| 910 # see if we can find a match | |
| 911 for ent in [line.strip().split(':') for line in | |
| 912 open(file).readlines()]: | |
| 913 if ent[0] == username: | |
| 914 return crypt.crypt(password, ent[1][:2]) == ent[1] | |
| 915 | |
| 916 # user doesn't exist in the file | |
| 917 return 0 | |
| 918 | |
| 919 def init(instance): | |
| 920 instance.registerAction('login', ExternalPasswordLoginAction) | |
| 921 | |
| 922 You should also remove the redundant password fields from the ``user.item`` | |
| 923 template. | |
| 924 | |
| 925 | |
| 926 Using a UN*X passwd file as the user database | |
| 927 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 928 | |
| 929 On some systems the primary store of users is the UN*X passwd file. It | |
| 930 holds information on users such as their username, real name, password | |
| 931 and primary user group. | |
| 932 | |
| 933 Roundup can use this store as its primary source of user information, | |
| 934 but it needs additional information too - email address(es), Roundup | |
| 935 Roles, vacation flags, Roundup hyperdb item ids, etc. Also, "retired" | |
| 936 users must still exist in the user database, unlike some passwd files in | |
| 937 which the users are removed when they no longer have access to a system. | |
| 938 | |
| 939 To make use of the passwd file, we therefore synchronise between the two | |
| 940 user stores. We also use the passwd file to validate the user logins, as | |
| 941 described in the previous example, `using an external password | |
| 942 validation source`_. We keep the user lists in sync using a fairly | |
| 943 simple script that runs once a day, or several times an hour if more | |
| 944 immediate access is needed. In short, it: | |
| 945 | |
| 946 1. parses the passwd file, finding usernames, passwords and real names, | |
| 947 2. compares that list to the current Roundup user list: | |
| 948 | |
| 949 a. entries no longer in the passwd file are *retired* | |
| 950 b. entries with mismatching real names are *updated* | |
| 951 c. entries only exist in the passwd file are *created* | |
| 952 | |
| 953 3. send an email to administrators to let them know what's been done. | |
| 954 | |
| 955 The retiring and updating are simple operations, requiring only a call | |
| 956 to ``retire()`` or ``set()``. The creation operation requires more | |
| 957 information though - the user's email address and their Roundup Roles. | |
| 958 We're going to assume that the user's email address is the same as their | |
| 959 login name, so we just append the domain name to that. The Roles are | |
| 960 determined using the passwd group identifier - mapping their UN*X group | |
| 961 to an appropriate set of Roles. | |
| 962 | |
| 963 The script to perform all this, broken up into its main components, is | |
| 964 as follows. Firstly, we import the necessary modules and open the | |
| 965 tracker we're to work on:: | |
| 966 | |
| 967 import sys, os, smtplib | |
| 968 from roundup import instance, date | |
| 969 | |
| 970 # open the tracker | |
| 971 tracker_home = sys.argv[1] | |
| 972 tracker = instance.open(tracker_home) | |
| 973 | |
| 974 Next we read in the *passwd* file from the tracker home:: | |
| 975 | |
| 976 # read in the users from the "passwd.txt" file | |
| 977 file = os.path.join(tracker_home, 'passwd.txt') | |
| 978 users = [x.strip().split(':') for x in open(file).readlines()] | |
| 979 | |
| 980 Handle special users (those to ignore in the file, and those who don't | |
| 981 appear in the file):: | |
| 982 | |
| 983 # users to not keep ever, pre-load with the users I know aren't | |
| 984 # "real" users | |
| 985 ignore = ['ekmmon', 'bfast', 'csrmail'] | |
| 986 | |
| 987 # users to keep - pre-load with the roundup-specific users | |
| 988 keep = ['comment_pool', 'network_pool', 'admin', 'dev-team', | |
| 989 'cs_pool', 'anonymous', 'system_pool', 'automated'] | |
| 990 | |
| 991 Now we map the UN*X group numbers to the Roles that users should have:: | |
| 992 | |
| 993 roles = { | |
| 994 '501': 'User,Tech', # tech | |
| 995 '502': 'User', # finance | |
| 996 '503': 'User,CSR', # customer service reps | |
| 997 '504': 'User', # sales | |
| 998 '505': 'User', # marketing | |
| 999 } | |
| 1000 | |
| 1001 Now we do all the work. Note that the body of the script (where we have | |
| 1002 the tracker database open) is wrapped in a ``try`` / ``finally`` clause, | |
| 1003 so that we always close the database cleanly when we're finished. So, we | |
| 1004 now do all the work:: | |
| 1005 | |
| 1006 # open the database | |
| 1007 db = tracker.open('admin') | |
| 1008 try: | |
| 1009 # store away messages to send to the tracker admins | |
| 1010 msg = [] | |
| 1011 | |
| 1012 # loop over the users list read in from the passwd file | |
| 1013 for user,passw,uid,gid,real,home,shell in users: | |
| 1014 if user in ignore: | |
| 1015 # this user shouldn't appear in our tracker | |
| 1016 continue | |
| 1017 keep.append(user) | |
| 1018 try: | |
| 1019 # see if the user exists in the tracker | |
| 1020 uid = db.user.lookup(user) | |
| 1021 | |
| 1022 # yes, they do - now check the real name for correctness | |
| 1023 if real != db.user.get(uid, 'realname'): | |
| 1024 db.user.set(uid, realname=real) | |
| 1025 msg.append('FIX %s - %s'%(user, real)) | |
| 1026 except KeyError: | |
| 1027 # nope, the user doesn't exist | |
| 1028 db.user.create(username=user, realname=real, | |
| 1029 address='%s@ekit-inc.com'%user, roles=roles[gid]) | |
| 1030 msg.append('ADD %s - %s (%s)'%(user, real, roles[gid])) | |
| 1031 | |
| 1032 # now check that all the users in the tracker are also in our | |
| 1033 # "keep" list - retire those who aren't | |
| 1034 for uid in db.user.list(): | |
| 1035 user = db.user.get(uid, 'username') | |
| 1036 if user not in keep: | |
| 1037 db.user.retire(uid) | |
| 1038 msg.append('RET %s'%user) | |
| 1039 | |
| 1040 # if we did work, then send email to the tracker admins | |
| 1041 if msg: | |
| 1042 # create the email | |
| 1043 msg = '''Subject: %s user database maintenance | |
| 1044 | |
| 1045 %s | |
| 1046 '''%(db.config.TRACKER_NAME, '\n'.join(msg)) | |
| 1047 | |
| 1048 # send the email | |
| 1049 smtp = smtplib.SMTP(db.config.MAILHOST) | |
| 1050 addr = db.config.ADMIN_EMAIL | |
| 1051 smtp.sendmail(addr, addr, msg) | |
| 1052 | |
| 1053 # now we're done - commit the changes | |
| 1054 db.commit() | |
| 1055 finally: | |
| 1056 # always close the database cleanly | |
| 1057 db.close() | |
| 1058 | |
| 1059 And that's it! | |
| 1060 | |
| 1061 | |
| 1062 Using an LDAP database for user information | |
| 1063 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 1064 | |
| 1065 A script that reads users from an LDAP store using | |
| 1066 https://pypi.org/project/python-ldap/ and then compares the list to the users in the | |
| 1067 Roundup user database would be pretty easy to write. You'd then have it run | |
| 1068 once an hour / day (or on demand if you can work that into your LDAP store | |
| 1069 workflow). See the example `Using a UN*X passwd file as the user database`_ | |
| 1070 for more information about doing this. | |
| 1071 | |
| 1072 To authenticate off the LDAP store (rather than using the passwords in the | |
| 1073 Roundup user database) you'd use the same python-ldap module inside an | |
| 1074 extension to the cgi interface. You'd do this by overriding the method called | |
| 1075 ``verifyPassword`` on the ``LoginAction`` class in your tracker's | |
| 1076 ``extensions`` directory (see `using an external password validation | |
| 1077 source`_). The method is implemented by default as:: | |
| 1078 | |
| 1079 def verifyPassword(self, userid, password): | |
| 1080 ''' Verify the password that the user has supplied | |
| 1081 ''' | |
| 1082 stored = self.db.user.get(self.userid, 'password') | |
| 1083 if password == stored: | |
| 1084 return 1 | |
| 1085 if not password and not stored: | |
| 1086 return 1 | |
| 1087 return 0 | |
| 1088 | |
| 1089 So you could reimplement this as something like:: | |
| 1090 | |
| 1091 def verifyPassword(self, userid, password): | |
| 1092 ''' Verify the password that the user has supplied | |
| 1093 ''' | |
| 1094 # look up some unique LDAP information about the user | |
| 1095 username = self.db.user.get(self.userid, 'username') | |
| 1096 # now verify the password supplied against the LDAP store | |
| 1097 | |
| 1098 | |
| 1099 Changes to Tracker Behaviour | |
| 1100 ---------------------------- | |
| 1101 | |
| 1102 .. index:: single: auditors; how to register (example) | |
| 1103 single: reactors; how to register (example) | |
| 1104 | |
| 1105 Preventing SPAM | |
| 1106 ~~~~~~~~~~~~~~~ | |
| 1107 | |
| 1108 The following detector code may be installed in your tracker's | |
| 1109 ``detectors`` directory. It will block any messages being created that | |
| 1110 have HTML attachments (a very common vector for spam and phishing) | |
| 1111 and any messages that have more than 2 HTTP URLs in them. Just copy | |
| 1112 the following into ``detectors/anti_spam.py`` in your tracker:: | |
| 1113 | |
| 1114 from roundup.exceptions import Reject | |
| 1115 | |
| 1116 def reject_html(db, cl, nodeid, newvalues): | |
| 1117 if newvalues['type'] == 'text/html': | |
| 1118 raise Reject('not allowed') | |
| 1119 | |
| 1120 def reject_manylinks(db, cl, nodeid, newvalues): | |
| 1121 content = newvalues['content'] | |
| 1122 if content.count('http://') > 2: | |
| 1123 raise Reject('not allowed') | |
| 1124 | |
| 1125 def init(db): | |
| 1126 db.file.audit('create', reject_html) | |
| 1127 db.msg.audit('create', reject_manylinks) | |
| 1128 | |
| 1129 You may also wish to block image attachments if your tracker does not | |
| 1130 need that ability:: | |
| 1131 | |
| 1132 if newvalues['type'].startswith('image/'): | |
| 1133 raise Reject('not allowed') | |
| 1134 | |
| 1135 | |
| 1136 Stop "nosy" messages going to people on vacation | |
| 1137 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 1138 | |
| 1139 When users go on vacation and set up vacation email bouncing, you'll | |
| 1140 start to see a lot of messages come back through Roundup "Fred is on | |
| 1141 vacation". Not very useful, and relatively easy to stop. | |
| 1142 | |
| 1143 1. add a "vacation" flag to your users:: | |
| 1144 | |
| 1145 user = Class(db, "user", | |
| 1146 username=String(), password=Password(), | |
| 1147 address=String(), realname=String(), | |
| 1148 phone=String(), organisation=String(), | |
| 1149 alternate_addresses=String(), | |
| 1150 roles=String(), queries=Multilink("query"), | |
| 1151 vacation=Boolean()) | |
| 1152 | |
| 1153 2. So that users may edit the vacation flags, add something like the | |
| 1154 following to your ``user.item`` template:: | |
| 1155 | |
| 1156 <tr> | |
| 1157 <th>On Vacation</th> | |
| 1158 <td tal:content="structure context/vacation/field">vacation</td> | |
| 1159 </tr> | |
| 1160 | |
| 1161 3. edit your detector ``nosyreactor.py`` so that the ``nosyreaction()`` | |
| 1162 consists of:: | |
| 1163 | |
| 1164 def nosyreaction(db, cl, nodeid, oldvalues): | |
| 1165 users = db.user | |
| 1166 messages = db.msg | |
| 1167 # send a copy of all new messages to the nosy list | |
| 1168 for msgid in determineNewMessages(cl, nodeid, oldvalues): | |
| 1169 try: | |
| 1170 # figure the recipient ids | |
| 1171 sendto = [] | |
| 1172 seen_message = {} | |
| 1173 recipients = messages.get(msgid, 'recipients') | |
| 1174 for recipid in messages.get(msgid, 'recipients'): | |
| 1175 seen_message[recipid] = 1 | |
| 1176 | |
| 1177 # figure the author's id, and indicate they've received | |
| 1178 # the message | |
| 1179 authid = messages.get(msgid, 'author') | |
| 1180 | |
| 1181 # possibly send the message to the author, as long as | |
| 1182 # they aren't anonymous | |
| 1183 if (db.config.MESSAGES_TO_AUTHOR == 'yes' and | |
| 1184 users.get(authid, 'username') != 'anonymous'): | |
| 1185 sendto.append(authid) | |
| 1186 seen_message[authid] = 1 | |
| 1187 | |
| 1188 # now figure the nosy people who weren't recipients | |
| 1189 nosy = cl.get(nodeid, 'nosy') | |
| 1190 for nosyid in nosy: | |
| 1191 # Don't send nosy mail to the anonymous user (that | |
| 1192 # user shouldn't appear in the nosy list, but just | |
| 1193 # in case they do...) | |
| 1194 if users.get(nosyid, 'username') == 'anonymous': | |
| 1195 continue | |
| 1196 # make sure they haven't seen the message already | |
| 1197 if nosyid not in seen_message: | |
| 1198 # send it to them | |
| 1199 sendto.append(nosyid) | |
| 1200 recipients.append(nosyid) | |
| 1201 | |
| 1202 # generate a change note | |
| 1203 if oldvalues: | |
| 1204 note = cl.generateChangeNote(nodeid, oldvalues) | |
| 1205 else: | |
| 1206 note = cl.generateCreateNote(nodeid) | |
| 1207 | |
| 1208 # we have new recipients | |
| 1209 if sendto: | |
| 1210 # filter out the people on vacation | |
| 1211 sendto = [i for i in sendto | |
| 1212 if not users.get(i, 'vacation', 0)] | |
| 1213 | |
| 1214 # map userids to addresses | |
| 1215 sendto = [users.get(i, 'address') for i in sendto] | |
| 1216 | |
| 1217 # update the message's recipients list | |
| 1218 messages.set(msgid, recipients=recipients) | |
| 1219 | |
| 1220 # send the message | |
| 1221 cl.send_message(nodeid, msgid, note, sendto) | |
| 1222 except roundupdb.MessageSendError as message: | |
| 1223 raise roundupdb.DetectorError(message) | |
| 1224 | |
| 1225 Note that this is the standard nosy reaction code, with the small | |
| 1226 addition of:: | |
| 1227 | |
| 1228 # filter out the people on vacation | |
| 1229 sendto = [i for i in sendto if not users.get(i, 'vacation', 0)] | |
| 1230 | |
| 1231 which filters out the users that have the vacation flag set to true. | |
| 1232 | |
| 1233 Adding in state transition control | |
| 1234 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 1235 | |
| 1236 Sometimes tracker admins want to control the states to which users may | |
| 1237 move issues. You can do this by following these steps: | |
| 1238 | |
| 1239 1. make "status" a required variable. This is achieved by adding the | |
| 1240 following to the top of the form in the ``issue.item.html`` | |
| 1241 template:: | |
| 1242 | |
| 1243 <input type="hidden" name="@required" value="status"> | |
| 1244 | |
| 1245 This will force users to select a status. | |
| 1246 | |
| 1247 2. add a Multilink property to the status class:: | |
| 1248 | |
| 1249 stat = Class(db, "status", ... , transitions=Multilink('status'), | |
| 1250 ...) | |
| 1251 | |
| 1252 and then edit the statuses already created, either: | |
| 1253 | |
| 1254 a. through the web using the class list -> status class editor, or | |
| 1255 b. using the ``roundup-admin`` "set" command. | |
| 1256 | |
| 1257 3. add an auditor module ``checktransition.py`` in your tracker's | |
| 1258 ``detectors`` directory, for example:: | |
| 1259 | |
| 1260 def checktransition(db, cl, nodeid, newvalues): | |
| 1261 ''' Check that the desired transition is valid for the "status" | |
| 1262 property. | |
| 1263 ''' | |
| 1264 if 'status' not in newvalues: | |
| 1265 return | |
| 1266 current = cl.get(nodeid, 'status') | |
| 1267 new = newvalues['status'] | |
| 1268 if new == current: | |
| 1269 return | |
| 1270 ok = db.status.get(current, 'transitions') | |
| 1271 if new not in ok: | |
| 1272 raise ValueError('Status not allowed to move from "%s" to "%s"'%( | |
| 1273 db.status.get(current, 'name'), db.status.get(new, 'name'))) | |
| 1274 | |
| 1275 def init(db): | |
| 1276 db.issue.audit('set', checktransition) | |
| 1277 | |
| 1278 4. in the ``issue.item.html`` template, change the status editing bit | |
| 1279 from:: | |
| 1280 | |
| 1281 <th>Status</th> | |
| 1282 <td tal:content="structure context/status/menu">status</td> | |
| 1283 | |
| 1284 to:: | |
| 1285 | |
| 1286 <th>Status</th> | |
| 1287 <td> | |
| 1288 <select tal:condition="context/id" name="status"> | |
| 1289 <tal:block tal:define="ok context/status/transitions" | |
| 1290 tal:repeat="state db/status/list"> | |
| 1291 <option tal:condition="python:state.id in ok" | |
| 1292 tal:attributes=" | |
| 1293 value state/id; | |
| 1294 selected python:state.id == context.status.id" | |
| 1295 tal:content="state/name"></option> | |
| 1296 </tal:block> | |
| 1297 </select> | |
| 1298 <tal:block tal:condition="not:context/id" | |
| 1299 tal:replace="structure context/status/menu" /> | |
| 1300 </td> | |
| 1301 | |
| 1302 which displays only the allowed status to transition to. | |
| 1303 | |
| 1304 | |
| 1305 Blocking issues that depend on other issues | |
| 1306 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 1307 | |
| 1308 We needed the ability to mark certain issues as "blockers" - that is, | |
| 1309 they can't be resolved until another issue (the blocker) they rely on is | |
| 1310 resolved. To achieve this: | |
| 1311 | |
| 1312 1. Create a new property on the ``issue`` class: | |
| 1313 ``blockers=Multilink("issue")``. To do this, edit the definition of | |
| 1314 this class in your tracker's ``schema.py`` file. Change this:: | |
| 1315 | |
| 1316 issue = IssueClass(db, "issue", | |
| 1317 assignedto=Link("user"), keyword=Multilink("keyword"), | |
| 1318 priority=Link("priority"), status=Link("status")) | |
| 1319 | |
| 1320 to this, adding the blockers entry:: | |
| 1321 | |
| 1322 issue = IssueClass(db, "issue", | |
| 1323 blockers=Multilink("issue"), | |
| 1324 assignedto=Link("user"), keyword=Multilink("keyword"), | |
| 1325 priority=Link("priority"), status=Link("status")) | |
| 1326 | |
| 1327 2. Add the new ``blockers`` property to the ``issue.item.html`` edit | |
| 1328 page, using something like:: | |
| 1329 | |
| 1330 <th>Waiting On</th> | |
| 1331 <td> | |
| 1332 <span tal:replace="structure python:context.blockers.field(showid=1, | |
| 1333 size=20)" /> | |
| 1334 <span tal:replace="structure python:db.issue.classhelp('id,title', | |
| 1335 property='blockers')" /> | |
| 1336 <span tal:condition="context/blockers" | |
| 1337 tal:repeat="blk context/blockers"> | |
| 1338 <br>View: <a tal:attributes="href string:issue${blk/id}" | |
| 1339 tal:content="blk/id"></a> | |
| 1340 </span> | |
| 1341 </td> | |
| 1342 | |
| 1343 You'll need to fiddle with your item page layout to find an | |
| 1344 appropriate place to put it - I'll leave that fun part up to you. | |
| 1345 Just make sure it appears in the first table, possibly somewhere near | |
| 1346 the "superseders" field. | |
| 1347 | |
| 1348 3. Create a new detector module (see below) which enforces the rules: | |
| 1349 | |
| 1350 - issues may not be resolved if they have blockers | |
| 1351 - when a blocker is resolved, it's removed from issues it blocks | |
| 1352 | |
| 1353 The contents of the detector should be something like this:: | |
| 1354 | |
| 1355 | |
| 1356 def blockresolution(db, cl, nodeid, newvalues): | |
| 1357 ''' If the issue has blockers, don't allow it to be resolved. | |
| 1358 ''' | |
| 1359 if nodeid is None: | |
| 1360 blockers = [] | |
| 1361 else: | |
| 1362 blockers = cl.get(nodeid, 'blockers') | |
| 1363 blockers = newvalues.get('blockers', blockers) | |
| 1364 | |
| 1365 # don't do anything if there's no blockers or the status hasn't | |
| 1366 # changed | |
| 1367 if not blockers or 'status' not in newvalues: | |
| 1368 return | |
| 1369 | |
| 1370 # get the resolved state ID | |
| 1371 resolved_id = db.status.lookup('resolved') | |
| 1372 | |
| 1373 # format the info | |
| 1374 u = db.config.TRACKER_WEB | |
| 1375 s = ', '.join(['<a href="%sissue%s">%s</a>'%( | |
| 1376 u,id,id) for id in blockers]) | |
| 1377 if len(blockers) == 1: | |
| 1378 s = 'issue %s is'%s | |
| 1379 else: | |
| 1380 s = 'issues %s are'%s | |
| 1381 | |
| 1382 # ok, see if we're trying to resolve | |
| 1383 if newvalues['status'] == resolved_id: | |
| 1384 raise ValueError("This issue can't be resolved until %s resolved."%s) | |
| 1385 | |
| 1386 | |
| 1387 def resolveblockers(db, cl, nodeid, oldvalues): | |
| 1388 ''' When we resolve an issue that's a blocker, remove it from the | |
| 1389 blockers list of the issue(s) it blocks. | |
| 1390 ''' | |
| 1391 newstatus = cl.get(nodeid,'status') | |
| 1392 | |
| 1393 # no change? | |
| 1394 if oldvalues.get('status', None) == newstatus: | |
| 1395 return | |
| 1396 | |
| 1397 resolved_id = db.status.lookup('resolved') | |
| 1398 | |
| 1399 # interesting? | |
| 1400 if newstatus != resolved_id: | |
| 1401 return | |
| 1402 | |
| 1403 # yes - find all the blocked issues, if any, and remove me from | |
| 1404 # their blockers list | |
| 1405 issues = cl.find(blockers=nodeid) | |
| 1406 for issueid in issues: | |
| 1407 blockers = cl.get(issueid, 'blockers') | |
| 1408 if nodeid in blockers: | |
| 1409 blockers.remove(nodeid) | |
| 1410 cl.set(issueid, blockers=blockers) | |
| 1411 | |
| 1412 def init(db): | |
| 1413 # might, in an obscure situation, happen in a create | |
| 1414 db.issue.audit('create', blockresolution) | |
| 1415 db.issue.audit('set', blockresolution) | |
| 1416 | |
| 1417 # can only happen on a set | |
| 1418 db.issue.react('set', resolveblockers) | |
| 1419 | |
| 1420 Put the above code in a file called "blockers.py" in your tracker's | |
| 1421 "detectors" directory. | |
| 1422 | |
| 1423 4. Finally, and this is an optional step, modify the tracker web page | |
| 1424 URLs so they filter out issues with any blockers. You do this by | |
| 1425 adding an additional filter on "blockers" for the value "-1". For | |
| 1426 example, the existing "Show All" link in the "page" template (in the | |
| 1427 tracker's "html" directory) looks like this:: | |
| 1428 | |
| 1429 <a href="#" | |
| 1430 tal:attributes="href python:request.indexargs_url('issue', { | |
| 1431 '@sort': '-activity', | |
| 1432 '@group': 'priority', | |
| 1433 '@filter': 'status', | |
| 1434 '@columns': columns_showall, | |
| 1435 '@search_text': '', | |
| 1436 'status': status_notresolved, | |
| 1437 '@dispname': i18n.gettext('Show All'), | |
| 1438 })" | |
| 1439 i18n:translate="">Show All</a><br> | |
| 1440 | |
| 1441 modify it to add the "blockers" info to the URL (note, both the | |
| 1442 "@filter" *and* "blockers" values must be specified):: | |
| 1443 | |
| 1444 <a href="#" | |
| 1445 tal:attributes="href python:request.indexargs_url('issue', { | |
| 1446 '@sort': '-activity', | |
| 1447 '@group': 'priority', | |
| 1448 '@filter': 'status,blockers', | |
| 1449 '@columns': columns_showall, | |
| 1450 '@search_text': '', | |
| 1451 'status': status_notresolved, | |
| 1452 'blockers': '-1', | |
| 1453 '@dispname': i18n.gettext('Show All'), | |
| 1454 })" | |
| 1455 i18n:translate="">Show All</a><br> | |
| 1456 | |
| 1457 The above examples are line-wrapped on the trailing & and should | |
| 1458 be unwrapped. | |
| 1459 | |
| 1460 That's it. You should now be able to set blockers on your issues. Note | |
| 1461 that if you want to know whether an issue has any other issues dependent | |
| 1462 on it (i.e. it's in their blockers list) you can look at the journal | |
| 1463 history at the bottom of the issue page - look for a "link" event to | |
| 1464 another issue's "blockers" property. | |
| 1465 | |
| 1466 Add users to the nosy list based on the keyword | |
| 1467 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 1468 | |
| 1469 Let's say we need the ability to automatically add users to the nosy | |
| 1470 list based | |
| 1471 on the occurance of a keyword. Every user should be allowed to edit their | |
| 1472 own list of keywords for which they want to be added to the nosy list. | |
| 1473 | |
| 1474 Below, we'll show that this change can be done with minimal | |
| 1475 understanding of the Roundup system, using only copy and paste. | |
| 1476 | |
| 1477 This requires three changes to the tracker: a change in the database to | |
| 1478 allow per-user recording of the lists of keywords for which he wants to | |
| 1479 be put on the nosy list, a change in the user view allowing them to edit | |
| 1480 this list of keywords, and addition of an auditor which updates the nosy | |
| 1481 list when a keyword is set. | |
| 1482 | |
| 1483 Adding the nosy keyword list | |
| 1484 :::::::::::::::::::::::::::: | |
| 1485 | |
| 1486 The change to make in the database, is that for any user there should be a list | |
| 1487 of keywords for which he wants to be put on the nosy list. Adding a | |
| 1488 ``Multilink`` of ``keyword`` seems to fullfill this. As such, all that has to | |
| 1489 be done is to add a new field to the definition of ``user`` within the file | |
| 1490 ``schema.py``. We will call this new field ``nosy_keywords``, and the updated | |
| 1491 definition of user will be:: | |
| 1492 | |
| 1493 user = Class(db, "user", | |
| 1494 username=String(), password=Password(), | |
| 1495 address=String(), realname=String(), | |
| 1496 phone=String(), organisation=String(), | |
| 1497 alternate_addresses=String(), | |
| 1498 queries=Multilink('query'), roles=String(), | |
| 1499 timezone=String(), | |
| 1500 nosy_keywords=Multilink('keyword')) | |
| 1501 | |
| 1502 Changing the user view to allow changing the nosy keyword list | |
| 1503 :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: | |
| 1504 | |
| 1505 We want any user to be able to change the list of keywords for which | |
| 1506 he will by default be added to the nosy list. We choose to add this | |
| 1507 to the user view, as is generated by the file ``html/user.item.html``. | |
| 1508 We can easily | |
| 1509 see that the keyword field in the issue view has very similar editing | |
| 1510 requirements as our nosy keywords, both being lists of keywords. As | |
| 1511 such, we look for Keywords in ``issue.item.html``, and extract the | |
| 1512 associated parts from there. We add this to ``user.item.html`` at the | |
| 1513 bottom of the list of viewed items (i.e. just below the 'Alternate | |
| 1514 E-mail addresses' in the classic template):: | |
| 1515 | |
| 1516 <tr> | |
| 1517 <th>Nosy Keywords</th> | |
| 1518 <td> | |
| 1519 <span tal:replace="structure context/nosy_keywords/field" /> | |
| 1520 <span tal:replace="structure python:db.keyword.classhelp(property='nosy_keywords')" /> | |
| 1521 </td> | |
| 1522 </tr> | |
| 1523 | |
| 1524 | |
| 1525 Addition of an auditor to update the nosy list | |
| 1526 :::::::::::::::::::::::::::::::::::::::::::::: | |
| 1527 | |
| 1528 The more difficult part is the logic to add | |
| 1529 the users to the nosy list when required. | |
| 1530 We choose to perform this action whenever the keywords on an | |
| 1531 item are set (this includes the creation of items). | |
| 1532 Here we choose to start out with a copy of the | |
| 1533 ``detectors/nosyreaction.py`` detector, which we copy to the file | |
| 1534 ``detectors/nosy_keyword_reaction.py``. | |
| 1535 This looks like a good start as it also adds users | |
| 1536 to the nosy list. A look through the code reveals that the | |
| 1537 ``nosyreaction`` function actually sends the e-mail. | |
| 1538 We don't need this. Therefore, we can change the ``init`` function to:: | |
| 1539 | |
| 1540 def init(db): | |
| 1541 db.issue.audit('create', update_kw_nosy) | |
| 1542 db.issue.audit('set', update_kw_nosy) | |
| 1543 | |
| 1544 After that, we rename the ``updatenosy`` function to ``update_kw_nosy``. | |
| 1545 The first two blocks of code in that function relate to setting | |
| 1546 ``current`` to a combination of the old and new nosy lists. This | |
| 1547 functionality is left in the new auditor. The following block of | |
| 1548 code, which handled adding the assignedto user(s) to the nosy list in | |
| 1549 ``updatenosy``, should be replaced by a block of code to add the | |
| 1550 interested users to the nosy list. We choose here to loop over all | |
| 1551 new keywords, than looping over all users, | |
| 1552 and assign the user to the nosy list when the keyword occurs in the user's | |
| 1553 ``nosy_keywords``. The next part in ``updatenosy`` -- adding the author | |
| 1554 and/or recipients of a message to the nosy list -- is obviously not | |
| 1555 relevant here and is thus deleted from the new auditor. The last | |
| 1556 part, copying the new nosy list to ``newvalues``, can stay as is. | |
| 1557 This results in the following function:: | |
| 1558 | |
| 1559 def update_kw_nosy(db, cl, nodeid, newvalues): | |
| 1560 '''Update the nosy list for changes to the keywords | |
| 1561 ''' | |
| 1562 # nodeid will be None if this is a new node | |
| 1563 current = {} | |
| 1564 if nodeid is None: | |
| 1565 ok = ('new', 'yes') | |
| 1566 else: | |
| 1567 ok = ('yes',) | |
| 1568 # old node, get the current values from the node if they haven't | |
| 1569 # changed | |
| 1570 if 'nosy' not in newvalues: | |
| 1571 nosy = cl.get(nodeid, 'nosy') | |
| 1572 for value in nosy: | |
| 1573 if value not in current: | |
| 1574 current[value] = 1 | |
| 1575 | |
| 1576 # if the nosy list changed in this transaction, init from the new value | |
| 1577 if 'nosy' in newvalues: | |
| 1578 nosy = newvalues.get('nosy', []) | |
| 1579 for value in nosy: | |
| 1580 if not db.hasnode('user', value): | |
| 1581 continue | |
| 1582 if value not in current: | |
| 1583 current[value] = 1 | |
| 1584 | |
| 1585 # add users with keyword in nosy_keywords to the nosy list | |
| 1586 if 'keyword' in newvalues and newvalues['keyword'] is not None: | |
| 1587 keyword_ids = newvalues['keyword'] | |
| 1588 for keyword in keyword_ids: | |
| 1589 # loop over all users, | |
| 1590 # and assign user to nosy when keyword in nosy_keywords | |
| 1591 for user_id in db.user.list(): | |
| 1592 nosy_kw = db.user.get(user_id, "nosy_keywords") | |
| 1593 found = 0 | |
| 1594 for kw in nosy_kw: | |
| 1595 if kw == keyword: | |
| 1596 found = 1 | |
| 1597 if found: | |
| 1598 current[user_id] = 1 | |
| 1599 | |
| 1600 # that's it, save off the new nosy list | |
| 1601 newvalues['nosy'] = list(current.keys()) | |
| 1602 | |
| 1603 These two function are the only ones needed in the file. | |
| 1604 | |
| 1605 TODO: update this example to use the ``find()`` Class method. | |
| 1606 | |
| 1607 Caveats | |
| 1608 ::::::: | |
| 1609 | |
| 1610 A few problems with the design here can be noted: | |
| 1611 | |
| 1612 Multiple additions | |
| 1613 When a user, after automatic selection, is manually removed | |
| 1614 from the nosy list, he is added to the nosy list again when the | |
| 1615 keyword list of the issue is updated. A better design might be | |
| 1616 to only check which keywords are new compared to the old list | |
| 1617 of keywords, and only add users when they have indicated | |
| 1618 interest on a new keyword. | |
| 1619 | |
| 1620 The code could also be changed to only trigger on the ``create()`` | |
| 1621 event, rather than also on the ``set()`` event, thus only setting | |
| 1622 the nosy list when the issue is created. | |
| 1623 | |
| 1624 Scalability | |
| 1625 In the auditor, there is a loop over all users. For a site with | |
| 1626 only few users this will pose no serious problem; however, with | |
| 1627 many users this will be a serious performance bottleneck. | |
| 1628 A way out would be to link from the keywords to the users who | |
| 1629 selected these keywords as nosy keywords. This will eliminate the | |
| 1630 loop over all users. See the ``rev_multilink`` attribute to make | |
| 1631 this easier. | |
| 1632 | |
| 1633 Restricting updates that arrive by email | |
| 1634 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 1635 | |
| 1636 Roundup supports multiple update methods: | |
| 1637 | |
| 1638 1. command line | |
| 1639 2. plain email | |
| 1640 3. pgp signed email | |
| 1641 4. web access | |
| 1642 | |
| 1643 in some cases you may need to prevent changes to properties by some of | |
| 1644 these methods. For example you can set up issues that are viewable | |
| 1645 only by people on the nosy list. So you must prevent unauthenticated | |
| 1646 changes to the nosy list. | |
| 1647 | |
| 1648 Since plain email can be easily forged, it does not provide sufficient | |
| 1649 authentication in this senario. | |
| 1650 | |
| 1651 To prevent this we can add a detector that audits the source of the | |
| 1652 transaction and rejects the update if it changes the nosy list. | |
| 1653 | |
| 1654 Create the detector (auditor) module and add it to the detectors | |
| 1655 directory of your tracker:: | |
| 1656 | |
| 1657 from roundup import roundupdb, hyperdb | |
| 1658 | |
| 1659 from roundup.mailgw import Unauthorized | |
| 1660 | |
| 1661 def restrict_nosy_changes(db, cl, nodeid, newvalues): | |
| 1662 '''Do not permit changes to nosy via email.''' | |
| 1663 | |
| 1664 if 'nosy' not in newvalues: | |
| 1665 # the nosy field has not changed so no need to check. | |
| 1666 return | |
| 1667 | |
| 1668 if db.tx_Source in ['web', 'rest', 'xmlrpc', 'email-sig-openpgp', 'cli' ]: | |
| 1669 # if the source of the transaction is from an authenticated | |
| 1670 # source or a privileged process allow the transaction. | |
| 1671 # Other possible sources: 'email' | |
| 1672 return | |
| 1673 | |
| 1674 # otherwise raise an error | |
| 1675 raise Unauthorized( \ | |
| 1676 'Changes to nosy property not allowed via %s for this issue.'%\ | |
| 1677 tx_Source) | |
| 1678 | |
| 1679 def init(db): | |
| 1680 ''' Install restrict_nosy_changes to run after other auditors. | |
| 1681 | |
| 1682 Allow initial creation email to set nosy. | |
| 1683 So don't execute: db.issue.audit('create', requestedbyauditor) | |
| 1684 | |
| 1685 Set priority to 110 to run this auditor after other auditors | |
| 1686 that can cause nosy to change. | |
| 1687 ''' | |
| 1688 db.issue.audit('set', restrict_nosy_changes, 110) | |
| 1689 | |
| 1690 This detector (auditor) will prevent updates to the nosy field if it | |
| 1691 arrives by email. Since it runs after other auditors (due to the | |
| 1692 priority of 110), it will also prevent changes to the nosy field that | |
| 1693 are done by other auditors if triggered by an email. | |
| 1694 | |
| 1695 Note that db.tx_Source was not present in roundup versions before | |
| 1696 1.4.22, so you must be running a newer version to use this detector. | |
| 1697 Read the CHANGES.txt document in the roundup source code for further | |
| 1698 details on tx_Source. | |
| 1699 | |
| 1700 Changes to Security and Permissions | |
| 1701 ----------------------------------- | |
| 1702 | |
| 1703 Restricting the list of users that are assignable to a task | |
| 1704 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 1705 | |
| 1706 1. In your tracker's ``schema.py``, create a new Role, say "Developer":: | |
| 1707 | |
| 1708 db.security.addRole(name='Developer', description='A developer') | |
| 1709 | |
| 1710 2. Just after that, create a new Permission, say "Fixer", specific to | |
| 1711 "issue":: | |
| 1712 | |
| 1713 p = db.security.addPermission(name='Fixer', klass='issue', | |
| 1714 description='User is allowed to be assigned to fix issues') | |
| 1715 | |
| 1716 3. Then assign the new Permission to your "Developer" Role:: | |
| 1717 | |
| 1718 db.security.addPermissionToRole('Developer', p) | |
| 1719 | |
| 1720 4. In the issue item edit page (``html/issue.item.html`` in your tracker | |
| 1721 directory), use the new Permission in restricting the "assignedto" | |
| 1722 list:: | |
| 1723 | |
| 1724 <select name="assignedto"> | |
| 1725 <option value="-1">- no selection -</option> | |
| 1726 <tal:block tal:repeat="user db/user/list"> | |
| 1727 <option tal:condition="python:user.hasPermission( | |
| 1728 'Fixer', context._classname)" | |
| 1729 tal:attributes=" | |
| 1730 value user/id; | |
| 1731 selected python:user.id == context.assignedto" | |
| 1732 tal:content="user/realname"></option> | |
| 1733 </tal:block> | |
| 1734 </select> | |
| 1735 | |
| 1736 For extra security, you may wish to setup an auditor to enforce the | |
| 1737 Permission requirement (install this as ``assignedtoFixer.py`` in your | |
| 1738 tracker ``detectors`` directory):: | |
| 1739 | |
| 1740 def assignedtoMustBeFixer(db, cl, nodeid, newvalues): | |
| 1741 ''' Ensure the assignedto value in newvalues is used with the | |
| 1742 Fixer Permission | |
| 1743 ''' | |
| 1744 if 'assignedto' not in newvalues: | |
| 1745 # don't care | |
| 1746 return | |
| 1747 | |
| 1748 # get the userid | |
| 1749 userid = newvalues['assignedto'] | |
| 1750 if not db.security.hasPermission('Fixer', userid, cl.classname): | |
| 1751 raise ValueError('You do not have permission to edit %s'%cl.classname) | |
| 1752 | |
| 1753 def init(db): | |
| 1754 db.issue.audit('set', assignedtoMustBeFixer) | |
| 1755 db.issue.audit('create', assignedtoMustBeFixer) | |
| 1756 | |
| 1757 So now, if an edit action attempts to set "assignedto" to a user that | |
| 1758 doesn't have the "Fixer" Permission, the error will be raised. | |
| 1759 | |
| 1760 | |
| 1761 Users may only edit their issues | |
| 1762 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 1763 | |
| 1764 In this case, users registering themselves are granted Provisional | |
| 1765 access, meaning they | |
| 1766 have access to edit the issues they submit, but not others. We create a new | |
| 1767 Role called "Provisional User" which is granted to newly-registered users, | |
| 1768 and has limited access. One of the Permissions they have is the new "Edit | |
| 1769 Own" on issues (regular users have "Edit".) | |
| 1770 | |
| 1771 First up, we create the new Role and Permission structure in | |
| 1772 ``schema.py``:: | |
| 1773 | |
| 1774 # | |
| 1775 # New users not approved by the admin | |
| 1776 # | |
| 1777 db.security.addRole(name='Provisional User', | |
| 1778 description='New user registered via web or email') | |
| 1779 | |
| 1780 # These users need to be able to view and create issues but only edit | |
| 1781 # and view their own | |
| 1782 db.security.addPermissionToRole('Provisional User', 'Create', 'issue') | |
| 1783 def own_issue(db, userid, itemid): | |
| 1784 '''Determine whether the userid matches the creator of the issue.''' | |
| 1785 return userid == db.issue.get(itemid, 'creator') | |
| 1786 p = db.security.addPermission(name='Edit', klass='issue', | |
| 1787 check=own_issue, description='Can only edit own issues') | |
| 1788 db.security.addPermissionToRole('Provisional User', p) | |
| 1789 p = db.security.addPermission(name='View', klass='issue', | |
| 1790 check=own_issue, description='Can only view own issues') | |
| 1791 db.security.addPermissionToRole('Provisional User', p) | |
| 1792 # This allows the interface to get the names of the properties | |
| 1793 # in the issue. Used for selecting sorting and grouping | |
| 1794 # on the index page. | |
| 1795 p = db.security.addPermission(name='Search', klass='issue') | |
| 1796 db.security.addPermissionToRole ('Provisional User', p) | |
| 1797 | |
| 1798 | |
| 1799 # Assign the Permissions for issue-related classes | |
| 1800 for cl in 'file', 'msg', 'query', 'keyword': | |
| 1801 db.security.addPermissionToRole('Provisional User', 'View', cl) | |
| 1802 db.security.addPermissionToRole('Provisional User', 'Edit', cl) | |
| 1803 db.security.addPermissionToRole('Provisional User', 'Create', cl) | |
| 1804 for cl in 'priority', 'status': | |
| 1805 db.security.addPermissionToRole('Provisional User', 'View', cl) | |
| 1806 | |
| 1807 # and give the new users access to the web and email interface | |
| 1808 db.security.addPermissionToRole('Provisional User', 'Web Access') | |
| 1809 db.security.addPermissionToRole('Provisional User', 'Email Access') | |
| 1810 | |
| 1811 # make sure they can view & edit their own user record | |
| 1812 def own_record(db, userid, itemid): | |
| 1813 '''Determine whether the userid matches the item being accessed.''' | |
| 1814 return userid == itemid | |
| 1815 p = db.security.addPermission(name='View', klass='user', check=own_record, | |
| 1816 description="User is allowed to view their own user details") | |
| 1817 db.security.addPermissionToRole('Provisional User', p) | |
| 1818 p = db.security.addPermission(name='Edit', klass='user', check=own_record, | |
| 1819 description="User is allowed to edit their own user details") | |
| 1820 db.security.addPermissionToRole('Provisional User', p) | |
| 1821 | |
| 1822 Then, in ``config.ini``, we change the Role assigned to newly-registered | |
| 1823 users, replacing the existing ``'User'`` values:: | |
| 1824 | |
| 1825 [main] | |
| 1826 ... | |
| 1827 new_web_user_roles = Provisional User | |
| 1828 new_email_user_roles = Provisional User | |
| 1829 | |
| 1830 | |
| 1831 All users may only view and edit issues, files and messages they create | |
| 1832 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 1833 | |
| 1834 Replace the standard "classic" tracker View and Edit Permission assignments | |
| 1835 for the "issue", "file" and "msg" classes with the following:: | |
| 1836 | |
| 1837 def checker(klass): | |
| 1838 def check(db, userid, itemid, klass=klass): | |
| 1839 return db.getclass(klass).get(itemid, 'creator') == userid | |
| 1840 return check | |
| 1841 for cl in 'issue', 'file', 'msg': | |
| 1842 p = db.security.addPermission(name='View', klass=cl, | |
| 1843 check=checker(cl), | |
| 1844 description='User can view only if creator.') | |
| 1845 db.security.addPermissionToRole('User', p) | |
| 1846 p = db.security.addPermission(name='Edit', klass=cl, | |
| 1847 check=checker(cl), | |
| 1848 description='User can edit only if creator.') | |
| 1849 db.security.addPermissionToRole('User', p) | |
| 1850 db.security.addPermissionToRole('User', 'Create', cl) | |
| 1851 # This allows the interface to get the names of the properties | |
| 1852 # in the issue. Used for selecting sorting and grouping | |
| 1853 # on the index page. | |
| 1854 p = db.security.addPermission(name='Search', klass='issue') | |
| 1855 db.security.addPermissionToRole ('User', p) | |
| 1856 | |
| 1857 | |
| 1858 Moderating user registration | |
| 1859 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 1860 | |
| 1861 You could set up new-user moderation in a public tracker by: | |
| 1862 | |
| 1863 1. creating a new highly-restricted user role "Pending", | |
| 1864 2. set the config new_web_user_roles and/or new_email_user_roles to that | |
| 1865 role, | |
| 1866 3. have an auditor that emails you when new users are created with that | |
| 1867 role using roundup.mailer | |
| 1868 4. edit the role to "User" for valid users. | |
| 1869 | |
| 1870 Some simple javascript might help in the last step. If you have high volume | |
| 1871 you could search for all currently-Pending users and do a bulk edit of all | |
| 1872 their roles at once (again probably with some simple javascript help). | |
| 1873 | |
| 1874 | |
| 1875 Changes to the Web User Interface | |
| 1260 --------------------------------- | 1876 --------------------------------- |
| 1261 | 1877 |
| 1262 Some examples are in the :ref:`CustomExamples` section below. | 1878 Adding action links to the index page |
| 1263 | 1879 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 1264 Also the `Roundup wiki`_ has additional examples of how schemas can be | 1880 |
| 1265 customised to add new functionality. | 1881 Add a column to the ``item.index.html`` template. |
| 1266 | 1882 |
| 1267 .. _Roundup wiki: | 1883 Resolving the issue:: |
| 1268 https://wiki.roundup-tracker.org/ | 1884 |
| 1269 | 1885 <a tal:attributes="href |
| 1270 .. index:: !detectors | 1886 string:issue${i/id}?:status=resolved&:action=edit">resolve</a> |
| 1271 | 1887 |
| 1272 Detectors - adding behaviour to your tracker | 1888 "Take" the issue:: |
| 1273 ============================================ | 1889 |
| 1274 .. _detectors: | 1890 <a tal:attributes="href |
| 1275 | 1891 string:issue${i/id}?:assignedto=${request/user/id}&:action=edit">take</a> |
| 1276 Detectors are initialised every time you open your tracker database, so | 1892 |
| 1277 you're free to add and remove them any time, even after the database is | 1893 ... and so on. |
| 1278 initialised via the ``roundup-admin initialise`` command. | 1894 |
| 1279 | 1895 Colouring the rows in the issue index according to priority |
| 1280 The detectors in your tracker fire *before* (**auditors**) and *after* | 1896 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 1281 (**reactors**) changes to the contents of your database. They are Python | 1897 |
| 1282 modules that sit in your tracker's ``detectors`` directory. You will | 1898 A simple ``tal:attributes`` statement will do the bulk of the work here. In |
| 1283 have some installed by default - have a look. You can write new | 1899 the ``issue.index.html`` template, add this to the ``<tr>`` that |
| 1284 detectors or modify the existing ones. The existing detectors installed | 1900 displays the rows of data:: |
| 1285 for you are: | 1901 |
| 1286 | 1902 <tr tal:attributes="class string:priority-${i/priority/plain}"> |
| 1287 .. index:: detectors; installed | 1903 |
| 1288 | 1904 and then in your stylesheet (``style.css``) specify the colouring for the |
| 1289 **nosyreaction.py** | 1905 different priorities, as follows:: |
| 1290 This provides the automatic nosy list maintenance and email sending. | 1906 |
| 1291 The nosy reactor (``nosyreaction``) fires when new messages are added | 1907 tr.priority-critical td { |
| 1292 to issues. The nosy auditor (``updatenosy``) fires when issues are | 1908 background-color: red; |
| 1293 changed, and figures out what changes need to be made to the nosy list | 1909 } |
| 1294 (such as adding new authors, etc.) | 1910 |
| 1295 **statusauditor.py** | 1911 tr.priority-urgent td { |
| 1296 This provides the ``chatty`` auditor which changes the issue status | 1912 background-color: orange; |
| 1297 from ``unread`` or ``closed`` to ``chatting`` if new messages appear. | 1913 } |
| 1298 It also provides the ``presetunread`` auditor which pre-sets the | 1914 |
| 1299 status to ``unread`` on new items if the status isn't explicitly | 1915 and so on, with far less offensive colours :) |
| 1300 defined. | 1916 |
| 1301 **messagesummary.py** | 1917 Editing multiple items in an index view |
| 1302 Generates the ``summary`` property for new messages based on the message | 1918 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 1303 content. | 1919 |
| 1304 **userauditor.py** | 1920 To edit the status of all items in the item index view, edit the |
| 1305 Verifies the content of some of the user fields (email addresses and | 1921 ``issue.index.html``: |
| 1306 roles lists). | 1922 |
| 1307 | 1923 1. add a form around the listing table (separate from the existing |
| 1308 If you don't want this default behaviour, you're completely free to change | 1924 index-page form), so at the top it reads:: |
| 1309 or remove these detectors. | 1925 |
| 1310 | 1926 <form method="POST" tal:attributes="action request/classname"> |
| 1311 See the detectors section in the `design document`__ for details of the | 1927 <table class="list"> |
| 1312 interface for detectors. | 1928 |
| 1313 | 1929 and at the bottom of that table:: |
| 1314 __ design.html | 1930 |
| 1315 | 1931 </table> |
| 1316 | 1932 </form |
| 1317 .. index:: detectors; writing api | 1933 |
| 1318 | 1934 making sure you match the ``</table>`` from the list table, not the |
| 1319 Detector API | 1935 navigation table or the subsequent form table. |
| 1320 ------------ | 1936 |
| 1321 | 1937 2. in the display for the issue property, change:: |
| 1322 .. index:: pair: detectors; auditors | 1938 |
| 1323 single: auditors; function signature | 1939 <td tal:condition="request/show/status" |
| 1324 single: auditors; defining | 1940 tal:content="python:i.status.plain() or default"> </td> |
| 1325 single: auditors; arguments | 1941 |
| 1326 | 1942 to:: |
| 1327 Auditors are called with the arguments:: | 1943 |
| 1328 | 1944 <td tal:condition="request/show/status" |
| 1329 audit(db, cl, itemid, newdata) | 1945 tal:content="structure i/status/field"> </td> |
| 1330 | 1946 |
| 1331 where ``db`` is the database, ``cl`` is an instance of Class or | 1947 this will result in an edit field for the status property. |
| 1332 IssueClass within the database, and ``newdata`` is a dictionary mapping | 1948 |
| 1333 property names to values. | 1949 3. after the ``tal:block`` which lists the index items (marked by |
| 1334 | 1950 ``tal:repeat="i batch"``) add a new table row:: |
| 1335 For a ``create()`` operation, the ``itemid`` argument is None and | 1951 |
| 1336 newdata contains all of the initial property values with which the item | 1952 <tr> |
| 1337 is about to be created. | 1953 <td tal:attributes="colspan python:len(request.columns)"> |
| 1338 | 1954 <input name="@csrf" type="hidden" |
| 1339 For a ``set()`` operation, newdata contains only the names and values of | 1955 tal:attributes="value python:utils.anti_csrf_nonce()"> |
| 1340 properties that are about to be changed. | 1956 <input type="submit" value=" Save Changes "> |
| 1341 | 1957 <input type="hidden" name="@action" value="edit"> |
| 1342 For a ``retire()`` or ``restore()`` operation, newdata is None. | 1958 <tal:block replace="structure request/indexargs_form" /> |
| 1343 | 1959 </td> |
| 1344 .. index:: pair: detectors; reactor | 1960 </tr> |
| 1345 single: reactors; function signature | 1961 |
| 1346 single: reactors; defining | 1962 which gives us a submit button, indicates that we are performing an |
| 1347 single: reactors; arguments | 1963 edit on any changed statuses, and provides a defense against cross |
| 1348 | 1964 site request forgery attacks. |
| 1349 Reactors are called with the arguments:: | 1965 |
| 1350 | 1966 The final ``tal:block`` will make sure that the current index view |
| 1351 react(db, cl, itemid, olddata) | 1967 parameters (filtering, columns, etc) will be used in rendering the |
| 1352 | 1968 next page (the results of the editing). |
| 1353 where ``db`` is the database, ``cl`` is an instance of Class or | 1969 |
| 1354 IssueClass within the database, and ``olddata`` is a dictionary mapping | 1970 |
| 1355 property names to values. | 1971 Displaying only message summaries in the issue display |
| 1356 | 1972 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 1357 For a ``create()`` operation, the ``itemid`` argument is the id of the | 1973 |
| 1358 newly-created item and ``olddata`` is None. | 1974 Alter the ``issue.item`` template section for messages to:: |
| 1359 | 1975 |
| 1360 For a ``set()`` operation, ``olddata`` contains the names and previous | 1976 <table class="messages" tal:condition="context/messages"> |
| 1361 values of properties that were changed. | 1977 <tr><th colspan="5" class="header">Messages</th></tr> |
| 1362 | 1978 <tr tal:repeat="msg context/messages"> |
| 1363 For a ``retire()`` or ``restore()`` operation, ``itemid`` is the id of | 1979 <td><a tal:attributes="href string:msg${msg/id}" |
| 1364 the retired or restored item and ``olddata`` is None. | 1980 tal:content="string:msg${msg/id}"></a></td> |
| 1365 | 1981 <td tal:content="msg/author">author</td> |
| 1366 .. index:: detectors; additional | 1982 <td class="date" tal:content="msg/date/pretty">date</td> |
| 1367 | 1983 <td tal:content="msg/summary">summary</td> |
| 1368 Additional Detectors Ready For Use | 1984 <td> |
| 1369 ---------------------------------- | 1985 <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit"> |
| 1370 | 1986 remove</a> |
| 1371 Sample additional detectors that have been found useful will appear in | 1987 </td> |
| 1372 the ``'detectors'`` directory of the Roundup distribution. If you want | 1988 </tr> |
| 1373 to use one, copy it to the ``'detectors'`` of your tracker instance: | 1989 </table> |
| 1374 | 1990 |
| 1375 **irker.py** | 1991 |
| 1376 This detector sends notification on IRC through an irker daemon | 1992 Enabling display of either message summaries or the entire messages |
| 1377 (http://www.catb.org/esr/irker/) when issues are created or messages | 1993 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 1378 are added. In order to use it you need to install irker, start the | 1994 |
| 1379 irkerd daemon, and add an ``[irker]`` section in ``detectors/config.ini`` | 1995 This is pretty simple - all we need to do is copy the code from the |
| 1380 that contains a comma-separated list of channels where the messages should | 1996 example `displaying only message summaries in the issue display`_ into |
| 1381 be sent, e.g. ``channels = irc://chat.freenode.net/channelname``. | 1997 our template alongside the summary display, and then introduce a switch |
| 1382 **newissuecopy.py** | 1998 that shows either the one or the other. We'll use a new form variable, |
| 1383 This detector sends an email to a team address whenever a new issue is | 1999 ``@whole_messages`` to achieve this:: |
| 1384 created. The address is hard-coded into the detector, so edit it | 2000 |
| 1385 before you use it (look for the text 'team@team.host') or you'll get | 2001 <table class="messages" tal:condition="context/messages"> |
| 1386 email errors! | 2002 <tal:block tal:condition="not:request/form/@whole_messages/value | python:0"> |
| 1387 **creator_resolution.py** | 2003 <tr><th colspan="3" class="header">Messages</th> |
| 1388 Catch attempts to set the status to "resolved" - if the assignedto | 2004 <th colspan="2" class="header"> |
| 1389 user isn't the creator, then set the status to "confirm-done". Note that | 2005 <a href="?@whole_messages=yes">show entire messages</a> |
| 1390 "classic" Roundup doesn't have that status, so you'll have to add it. If | 2006 </th> |
| 1391 you don't want to though, it'll just use "in-progress" instead. | 2007 </tr> |
| 1392 **email_auditor.py** | 2008 <tr tal:repeat="msg context/messages"> |
| 1393 If a file added to an issue is of type message/rfc822, we tack on the | 2009 <td><a tal:attributes="href string:msg${msg/id}" |
| 1394 extension .eml. | 2010 tal:content="string:msg${msg/id}"></a></td> |
| 1395 The reason for this is that Microsoft Internet Explorer will not open | 2011 <td tal:content="msg/author">author</td> |
| 1396 things with a .eml attachment, as they deem it 'unsafe'. Worse yet, | 2012 <td class="date" tal:content="msg/date/pretty">date</td> |
| 1397 they'll just give you an incomprehensible error message. For more | 2013 <td tal:content="msg/summary">summary</td> |
| 1398 information, see the detector code - it has a length explanation. | 2014 <td> |
| 1399 | 2015 <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a> |
| 1400 | 2016 </td> |
| 1401 .. index:: auditors; rules for use | 2017 </tr> |
| 1402 single: reactors; rules for use | 2018 </tal:block> |
| 1403 | 2019 |
| 1404 Auditor or Reactor? | 2020 <tal:block tal:condition="request/form/@whole_messages/value | python:0"> |
| 1405 ------------------- | 2021 <tr><th colspan="2" class="header">Messages</th> |
| 1406 | 2022 <th class="header"> |
| 1407 Generally speaking, the following rules should be observed: | 2023 <a href="?@whole_messages=">show only summaries</a> |
| 1408 | 2024 </th> |
| 1409 **Auditors** | 2025 </tr> |
| 1410 Are used for `vetoing creation of or changes to items`_. They might | 2026 <tal:block tal:repeat="msg context/messages"> |
| 1411 also make automatic changes to item properties. | 2027 <tr> |
| 1412 **Reactors** | 2028 <th tal:content="msg/author">author</th> |
| 1413 Detect changes in the database and react accordingly. They should avoid | 2029 <th class="date" tal:content="msg/date/pretty">date</th> |
| 1414 making changes to the database where possible, as this could create | 2030 <th style="text-align: right"> |
| 1415 detector loops. | 2031 (<a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a>) |
| 1416 | 2032 </th> |
| 1417 | 2033 </tr> |
| 1418 Vetoing creation of or changes to items | 2034 <tr><td colspan="3" tal:content="msg/content"></td></tr> |
| 1419 --------------------------------------- | 2035 </tal:block> |
| 1420 | 2036 </tal:block> |
| 1421 Auditors may raise the ``Reject`` exception to prevent the creation of | 2037 </table> |
| 1422 or changes to items in the database. The mail gateway, for example, will | 2038 |
| 1423 not attach files or messages to issues when the creation of those files or | 2039 |
| 1424 messages are prevented through the ``Reject`` exception. It'll also not create | 2040 Setting up a "wizard" (or "druid") for controlled adding of issues |
| 1425 users if that creation is ``Reject``'ed too. | 2041 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 1426 | 2042 |
| 1427 To use, simply add at the top of your auditor:: | 2043 1. Set up the page templates you wish to use for data input. My wizard |
| 1428 | 2044 is going to be a two-step process: first figuring out what category |
| 1429 from roundup.exceptions import Reject | 2045 of issue the user is submitting, and then getting details specific to |
| 1430 | 2046 that category. The first page includes a table of help, explaining |
| 1431 And then when your rejection criteria have been detected, simply:: | 2047 what the category names mean, and then the core of the form:: |
| 1432 | 2048 |
| 1433 raise Reject('Description of error') | 2049 <form method="POST" onSubmit="return submit_once()" |
| 1434 | 2050 enctype="multipart/form-data"> |
| 1435 Error messages raised with ``Reject`` automatically have any HTML content | 2051 <input name="@csrf" type="hidden" |
| 1436 escaped before being displayed to the user. To display an error message to the | 2052 tal:attributes="value python:utils.anti_csrf_nonce()"> |
| 1437 user without performing any HTML escaping the ``RejectRaw`` should be used. All | 2053 <input type="hidden" name="@template" value="add_page1"> |
| 1438 security implications should be carefully considering before using | 2054 <input type="hidden" name="@action" value="page1_submit"> |
| 1439 ``RejectRaw``. | 2055 |
| 1440 | 2056 <strong>Category:</strong> |
| 1441 | 2057 <tal:block tal:replace="structure context/category/menu" /> |
| 1442 Generating email from Roundup | 2058 <input type="submit" value="Continue"> |
| 1443 ----------------------------- | 2059 </form> |
| 1444 | 2060 |
| 1445 The module ``roundup.mailer`` contains most of the nuts-n-bolts required | 2061 The next page has the usual issue entry information, with the |
| 1446 to generate email messages from Roundup. | 2062 addition of the following form fragments:: |
| 1447 | 2063 |
| 1448 In addition, the ``IssueClass`` methods ``nosymessage()`` and | 2064 <form method="POST" onSubmit="return submit_once()" |
| 1449 ``send_message()`` are used to generate nosy messages, and may generate | 2065 enctype="multipart/form-data" |
| 1450 messages which only consist of a change note (ie. the message id parameter | 2066 tal:condition="context/is_edit_ok" |
| 1451 is not required - this is referred to as a "System Message" because it | 2067 tal:define="cat request/form/category/value"> |
| 1452 comes from "the system" and not a user). | 2068 |
| 1453 | 2069 <input name="@csrf" type="hidden" |
| 1454 | 2070 tal:attributes="value python:utils.anti_csrf_nonce()"> |
| 1455 .. index:: extensions | 2071 <input type="hidden" name="@template" value="add_page2"> |
| 1456 .. index:: extensions; add python functions to tracker | 2072 <input type="hidden" name="@required" value="title"> |
| 1457 | 2073 <input type="hidden" name="category" tal:attributes="value cat"> |
| 1458 Extensions - adding capabilities to your tracker | 2074 . |
| 1459 ================================================ | 2075 . |
| 1460 .. _extensions: | 2076 . |
| 1461 | 2077 </form> |
| 1462 While detectors_ add new behavior by reacting to changes in tracked | 2078 |
| 1463 objects, `extensions` add new actions and utilities to Roundup, which | 2079 Note that later in the form, I use the value of "cat" to decide which |
| 1464 are mostly used to enhance web interface. | 2080 form elements should be displayed. For example:: |
| 1465 | 2081 |
| 1466 You can create an extension by creating Python file in your tracker | 2082 <tal:block tal:condition="python:cat in '6 10 13 14 15 16 17'.split()"> |
| 1467 ``extensions`` directory. All files from this dir are loaded when | 2083 <tr> |
| 1468 tracker instance is created, at which point it calls ``init(instance)`` | 2084 <th>Operating System</th> |
| 1469 from each file supplying itself as a first argument. | 2085 <td tal:content="structure context/os/field"></td> |
| 1470 | 2086 </tr> |
| 1471 Note that at this point web interface is not loaded, but extensions still | 2087 <tr> |
| 1472 can register actions for in tracker instance. This may be fixed in | 2088 <th>Web Browser</th> |
| 1473 Roundup 1.6 by introducing ``init_web(client)`` callback or a more | 2089 <td tal:content="structure context/browser/field"></td> |
| 1474 flexible extension point mechanism. | 2090 </tr> |
| 1475 | 2091 </tal:block> |
| 1476 | 2092 |
| 1477 * ``instance.registerUtil`` is used for adding `templating utilities`_ | 2093 ... the above section will only be displayed if the category is one |
| 1478 (see `adding a time log to your issues`_ for an example) | 2094 of 6, 10, 13, 14, 15, 16 or 17. |
| 1479 | 2095 |
| 1480 * ``instance.registerAction`` is used to add more actions to instance | 2096 3. Determine what actions need to be taken between the pages - these are |
| 1481 and to web interface. See `Defining new web actions`_ for details. | 2097 usually to validate user choices and determine what page is next. Now encode |
| 1482 Generic action can be added by inheriting from ``action.Action`` | 2098 those actions in a new ``Action`` class (see |
| 1483 instead of ``cgi.action.Action``. | 2099 `defining new web actions <reference.html#defining-new-web-actions>`_):: |
| 1484 | 2100 |
| 1485 interfaces.py - hooking into the core of roundup | 2101 from roundup.cgi.actions import Action |
| 1486 ================================================ | 2102 |
| 1487 .. _interfaces.py: | 2103 class Page1SubmitAction(Action): |
| 1488 | 2104 def handle(self): |
| 1489 There is a magic trick for hooking into the core of roundup. Using | 2105 ''' Verify that the user has selected a category, and then move |
| 1490 this you can: | 2106 on to page 2. |
| 1491 | 2107 ''' |
| 1492 * modify class data structures | 2108 category = self.form['category'].value |
| 1493 * monkey patch core code to add new functionality | 2109 if category == '-1': |
| 1494 * modify the email gateway | 2110 self.client.add_error_message('You must select a category of report') |
| 1495 * add new rest endpoints | 2111 return |
| 1496 | 2112 # everything's ok, move on to the next page |
| 1497 but with great power comes great responsibility. | 2113 self.client.template = 'add_page2' |
| 1498 | 2114 |
| 1499 Interfaces.py has been around since the earliest releases of roundup | 2115 def init(instance): |
| 1500 and used to be the main way to get a lot of customization done. In | 2116 instance.registerAction('page1_submit', Page1SubmitAction) |
| 1501 modern roundup, the extensions_ mechanism is used, but there are | 2117 |
| 1502 places where interfaces.py is still useful. Note that the tracker | 2118 4. Use the usual "new" action as the ``@action`` on the final page, and |
| 1503 directories are not on the Python system path when interfaces.py is | 2119 you're done (the standard context/submit method can do this for you). |
| 1504 evaluated. You need to add library directories explictly by | 2120 |
| 1505 modifying sys.path. | 2121 |
| 1506 | 2122 Silent Submit |
| 1507 Example: Changing Cache-Control headers | 2123 ~~~~~~~~~~~~~ |
| 1508 --------------------------------------- | 2124 |
| 2125 When working on an issue, most of the time the people on the nosy list | |
| 2126 need to be notified of changes. There are cases where a user wants to | |
| 2127 add a comment to an issue and not bother other users on the nosy | |
| 2128 list. | |
| 2129 This feature is called Silent Submit because it allows the user to | |
| 2130 silently modify an issue and not tell anyone. | |
| 2131 | |
| 2132 There are several parts to this change. The main activity part | |
| 2133 involves editing the stock detectors/nosyreaction.py file in your | |
| 2134 tracker. Insert the following lines near the top of the nosyreaction | |
| 2135 function:: | |
| 2136 | |
| 2137 # Did user click button to do a silent change? | |
| 2138 try: | |
| 2139 if db.web['submit'] == "silent_change": | |
| 2140 return | |
| 2141 except (AttributeError, KeyError) as err: | |
| 2142 # The web attribute or submit key don't exist. | |
| 2143 # That's fine. We were probably triggered by an email | |
| 2144 # or cli based change. | |
| 2145 pass | |
| 2146 | |
| 2147 This checks the submit button to see if it is the silent type. If there | |
| 2148 are exceptions trying to make that determination they are ignored and | |
| 2149 processing continues. You may wonder how db.web gets set. This is done | |
| 2150 by creating an extension. Add the file extensions/edit.py with | |
| 2151 this content:: | |
| 2152 | |
| 2153 from roundup.cgi.actions import EditItemAction | |
| 2154 | |
| 2155 class Edit2Action(EditItemAction): | |
| 2156 def handle(self): | |
| 2157 self.db.web = {} # create the dict | |
| 2158 # populate the dict by getting the value of the submit_button | |
| 2159 # element from the form. | |
| 2160 self.db.web['submit'] = self.form['submit_button'].value | |
| 2161 | |
| 2162 # call the core EditItemAction to process the edit. | |
| 2163 EditItemAction.handle(self) | |
| 2164 | |
| 2165 def init(instance): | |
| 2166 '''Override the default edit action with this new version''' | |
| 2167 instance.registerAction('edit', Edit2Action) | |
| 2168 | |
| 2169 This code is a wrapper for the Roundup EditItemAction. It checks the | |
| 2170 form's submit button to save the value element. The rest of the changes | |
| 2171 needed for the Silent Submit feature involves editing | |
| 2172 html/issue.item.html to add the silent submit button. In | |
| 2173 the stock issue.item.html the submit button is on a line that contains | |
| 2174 "submit button". Replace that line with something like the following:: | |
| 2175 | |
| 2176 <input type="submit" name="submit_button" | |
| 2177 tal:condition="context/is_edit_ok" | |
| 2178 value="Submit Changes"> | |
| 2179 <button type="submit" name="submit_button" | |
| 2180 tal:condition="context/is_edit_ok" | |
| 2181 title="Click this to submit but not send nosy email." | |
| 2182 value="silent_change" i18n:translate=""> | |
| 2183 Silent Change</button> | |
| 2184 | |
| 2185 Note the difference in the value attribute for the two submit buttons. | |
| 2186 The value "silent_change" in the button specification must match the | |
| 2187 string in the nosy reaction function. | |
| 2188 | |
| 2189 Changing How the Core Code Works | |
| 2190 -------------------------------- | |
| 2191 | |
| 2192 Changing Cache-Control Headers | |
| 2193 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 1509 | 2194 |
| 1510 The Client class in cgi/client.py has a lookup table that is used to | 2195 The Client class in cgi/client.py has a lookup table that is used to |
| 1511 set the Cache-Control headers for static files. The entries in this | 2196 set the Cache-Control headers for static files. The entries in this |
| 1512 table are set from interfaces.py using:: | 2197 table are set from interfaces.py using:: |
| 1513 | 2198 |
| 1520 | 2205 |
| 1521 In this case static files delivered using @@file will have cache | 2206 In this case static files delivered using @@file will have cache |
| 1522 headers set. These files are searched for along the `static_files` | 2207 headers set. These files are searched for along the `static_files` |
| 1523 path in the tracker's `config.ini`. In the example above: | 2208 path in the tracker's `config.ini`. In the example above: |
| 1524 | 2209 |
| 1525 * a css file (e.g. @@file/style.css) will be cached for an hour | 2210 * a css file (e.g. @@file/style.css) will be cached for an hour |
| 1526 * javascript files (e.g. @@file/libraries/jquery.js) will be cached | 2211 * javascript files (e.g. @@file/libraries/jquery.js) will be cached |
| 1527 for 30 seconds | 2212 for 30 seconds |
| 1528 * a file named rss.xml will be cached for 15 minutes | 2213 * a file named rss.xml will be cached for 15 minutes |
| 1529 * a file named local.js will be cached for 2 hours | 2214 * a file named local.js will be cached for 2 hours |
| 1530 | 2215 |
| 1531 Note that a file name match overrides the mime type settings. | 2216 Note that a file name match overrides the mime type settings. |
| 1532 | 2217 |
| 1533 | 2218 |
| 1534 Example: Implement password complexity checking | 2219 Implement Password Complexity Checking |
| 1535 ----------------------------------------------- | 2220 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 1536 | 2221 |
| 1537 .. index:: tracker; lib directory (example) | 2222 .. index:: tracker; lib directory (example) |
| 1538 | 2223 |
| 1539 This example uses the zxcvbn_ module that you can place in the zxcvbn | 2224 This example uses the zxcvbn_ module that you can place in the zxcvbn |
| 1540 subdirectory of your tracker's lib directory. | 2225 subdirectory of your tracker's lib directory. |
| 1567 | 2252 |
| 1568 it replaces the setPassword method in the Password class. The new | 2253 it replaces the setPassword method in the Password class. The new |
| 1569 version validates that the password is sufficiently complex. Then it | 2254 version validates that the password is sufficiently complex. Then it |
| 1570 passes off the setting of password to the original method. | 2255 passes off the setting of password to the original method. |
| 1571 | 2256 |
| 1572 Example: Enhance time intervals | 2257 Enhance Time Intervals Forms |
| 1573 ------------------------------- | 2258 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 1574 | 2259 |
| 1575 To make the user interface easier to use, you may want to support | 2260 To make the user interface easier to use, you may want to support |
| 1576 other forms for intervals. For example you can support an interval | 2261 other forms for intervals. For example you can support an interval |
| 1577 like 1.5 by interpreting it the same as 1:30 (1 hour 30 minutes). | 2262 like 1.5 by interpreting it the same as 1:30 (1 hour 30 minutes). |
| 1578 Also you can allow a bare integer (e.g. 45) as a number of minutes. | 2263 Also you can allow a bare integer (e.g. 45) as a number of minutes. |
| 1614 hyperdb.Interval.from_raw = normalizeperiod | 2299 hyperdb.Interval.from_raw = normalizeperiod |
| 1615 | 2300 |
| 1616 any call to convert an interval from raw form now has two simpler | 2301 any call to convert an interval from raw form now has two simpler |
| 1617 (and more friendly) ways to specify common time intervals. | 2302 (and more friendly) ways to specify common time intervals. |
| 1618 | 2303 |
| 1619 Example: Modifying the mail gateway | 2304 Modifying the Mail Gateway |
| 1620 ----------------------------------- | 2305 ~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 1621 | 2306 |
| 1622 One site receives email on a main gateway. The virtual alias delivery | 2307 One site receives email on a main gateway. The virtual alias delivery |
| 1623 table on the postfix server is configured with:: | 2308 table on the postfix server is configured with:: |
| 1624 | 2309 |
| 1625 test-issues@example.com roundup-test@roundup-vm.example.com | 2310 test-issues@example.com roundup-test@roundup-vm.example.com |
| 1626 test-support@example.com roundup-test+support-a@roundup-vm.example.com | 2311 test-support@example.com roundup-test+support-a@roundup-vm.example.com |
| 1627 test@support.example.com roundup-test+support-b@roundup-vm.example.com | 2312 test@support.example.com roundup-test+support-b@roundup-vm.example.com |
| 1628 | 2313 |
| 1629 These modifications to the mail gateway for roundup allows anonymous | 2314 These modifications to the mail gateway for Roundup allows anonymous |
| 1630 submissions. It hides all of the requesters under the "support" | 2315 submissions. It hides all of the requesters under the "support" |
| 1631 user. It also makes some other modifications to the mail parser | 2316 user. It also makes some other modifications to the mail parser |
| 1632 allowing keywords to be set and prefixes to be defined based on the | 2317 allowing keywords to be set and prefixes to be defined based on the |
| 1633 delivery alias. | 2318 delivery alias. |
| 1634 | 2319 |
| 1737 else: | 2422 else: |
| 1738 # parse the message normally | 2423 # parse the message normally |
| 1739 return roundup.mailgw.parsedMessage(mailgw, message) | 2424 return roundup.mailgw.parsedMessage(mailgw, message) |
| 1740 | 2425 |
| 1741 This is the most complex example section. The mail gateway is also one | 2426 This is the most complex example section. The mail gateway is also one |
| 1742 of the more complex subsystems in roundup, and modifying it is not | 2427 of the more complex subsystems in Roundup, and modifying it is not |
| 1743 trivial. | 2428 trivial. |
| 1744 | 2429 |
| 1745 Other Examples | 2430 Other Examples |
| 1746 -------------- | 2431 ============== |
| 1747 | 2432 |
| 1748 See the `rest interface documentation`_ for instructions on how to add | 2433 See the `rest interface documentation`_ for instructions on how to add |
| 1749 new rest endpoints using interfaces.py. | 2434 new rest endpoints or `change the rate limiting method`_ using interfaces.py. |
| 1750 | 2435 |
| 1751 Database Content | 2436 The `reference document`_ also has examples: |
| 1752 ================ | 2437 |
| 1753 | 2438 * `Extending the configuration file |
| 1754 .. note:: | 2439 <reference.html#extending-the-configuration-file>`_. |
| 1755 If you modify the content of definitional classes, you'll most | 2440 * `Adding a new Permission <reference.html#adding-a-new-permission>`_ |
| 1756 likely need to edit the tracker `detectors`_ to reflect your changes. | 2441 |
| 1757 | 2442 Examples on the Wiki |
| 1758 Customisation of the special "definitional" classes (eg. status, | 2443 ==================== |
| 1759 priority, resolution, ...) may be done either before or after the | 2444 |
| 1760 tracker is initialised. The actual method of doing so is completely | 2445 Even more examples of customization have been contributed by |
| 1761 different in each case though, so be careful to use the right one. | 2446 users. They can be found on the `wiki |
| 1762 | 2447 <https://wiki.roundup-tracker.org/CustomisationExamples>`_. |
| 1763 **Changing content before tracker initialisation** | |
| 1764 Edit the initial_data.py module in your tracker to alter the items | |
| 1765 created using the ``create( ... )`` methods. | |
| 1766 | |
| 1767 **Changing content after tracker initialisation** | |
| 1768 As the "admin" user, click on the "class list" link in the web | |
| 1769 interface to bring up a list of all database classes. Click on the | |
| 1770 name of the class you wish to change the content of. | |
| 1771 | |
| 1772 You may also use the ``roundup-admin`` interface's create, set and | |
| 1773 retire methods to add, alter or remove items from the classes in | |
| 1774 question. | |
| 1775 | |
| 1776 See "`adding a new field to the classic schema`_" for an example that | |
| 1777 requires database content changes. | |
| 1778 | |
| 1779 | |
| 1780 Security / Access Controls | |
| 1781 ========================== | |
| 1782 | |
| 1783 A set of Permissions is built into the security module by default: | |
| 1784 | |
| 1785 - Create (everything) | |
| 1786 - Edit (everything) | |
| 1787 - Search (everything) (used if View does not permit access) | |
| 1788 - View (everything) | |
| 1789 - Register (User class only) | |
| 1790 | |
| 1791 These are assigned to the "Admin" Role by default, and allow a user to do | |
| 1792 anything. Every Class you define in your `tracker schema`_ also gets an | |
| 1793 Create, Edit and View Permission of its own. The web and email interfaces | |
| 1794 also define: | |
| 1795 | |
| 1796 *Email Access* | |
| 1797 If defined, the user may use the email interface. Used by default to deny | |
| 1798 Anonymous users access to the email interface. When granted to the | |
| 1799 Anonymous user, they will be automatically registered by the email | |
| 1800 interface (see also the ``new_email_user_roles`` configuration option). | |
| 1801 *Web Access* | |
| 1802 If defined, the user may use the web interface. All users are able to see | |
| 1803 the login form, regardless of this setting (thus enabling logging in). | |
| 1804 *Web Roles* | |
| 1805 Controls user access to editing the "roles" property of the "user" class. | |
| 1806 TODO: deprecate in favour of a property-based control. | |
| 1807 *Rest Access* and *Xmlrpc Access* | |
| 1808 These control access to the Rest and Xmlrpc endpoints. The Admin and User | |
| 1809 roles have these by default in the classic tracker. See the | |
| 1810 `directions in the rest interface documentation`_ and the | |
| 1811 `xmlrpc interface documentation`_. | |
| 1812 | |
| 1813 These are hooked into the default Roles: | |
| 1814 | |
| 1815 - Admin (Create, Edit, Search, View and everything; Web Roles) | |
| 1816 - User (Web Access; Email Access) | |
| 1817 - Anonymous (Web Access) | |
| 1818 | |
| 1819 And finally, the "admin" user gets the "Admin" Role, and the "anonymous" | |
| 1820 user gets "Anonymous" assigned when the tracker is installed. | |
| 1821 | |
| 1822 For the "User" Role, the "classic" tracker defines: | |
| 1823 | |
| 1824 - Create, Edit and View issue, file, msg, query, keyword | |
| 1825 - View priority, status | |
| 1826 - View user | |
| 1827 - Edit their own user record | |
| 1828 | |
| 1829 And the "Anonymous" Role is defined as: | |
| 1830 | |
| 1831 - Web interface access | |
| 1832 - Register user (for registration) | |
| 1833 - View issue, file, msg, query, keyword, priority, status | |
| 1834 | |
| 1835 Put together, these settings appear in the tracker's ``schema.py`` file:: | |
| 1836 | |
| 1837 # | |
| 1838 # TRACKER SECURITY SETTINGS | |
| 1839 # | |
| 1840 # See the configuration and customisation document for information | |
| 1841 # about security setup. | |
| 1842 | |
| 1843 # | |
| 1844 # REGULAR USERS | |
| 1845 # | |
| 1846 # Give the regular users access to the web and email interface | |
| 1847 db.security.addPermissionToRole('User', 'Web Access') | |
| 1848 db.security.addPermissionToRole('User', 'Email Access') | |
| 1849 db.security.addPermissionToRole('User', 'Rest Access') | |
| 1850 db.security.addPermissionToRole('User', 'Xmlrpc Access') | |
| 1851 | |
| 1852 # Assign the access and edit Permissions for issue, file and message | |
| 1853 # to regular users now | |
| 1854 for cl in 'issue', 'file', 'msg', 'keyword': | |
| 1855 db.security.addPermissionToRole('User', 'View', cl) | |
| 1856 db.security.addPermissionToRole('User', 'Edit', cl) | |
| 1857 db.security.addPermissionToRole('User', 'Create', cl) | |
| 1858 for cl in 'priority', 'status': | |
| 1859 db.security.addPermissionToRole('User', 'View', cl) | |
| 1860 | |
| 1861 # May users view other user information? Comment these lines out | |
| 1862 # if you don't want them to | |
| 1863 p = db.security.addPermission(name='View', klass='user', | |
| 1864 properties=('id', 'organisation', 'phone', 'realname', 'timezone', | |
| 1865 'username')) | |
| 1866 db.security.addPermissionToRole('User', p) | |
| 1867 | |
| 1868 # Users should be able to edit their own details -- this permission is | |
| 1869 # limited to only the situation where the Viewed or Edited item is their own. | |
| 1870 def own_record(db, userid, itemid, **ctx): | |
| 1871 '''Determine whether the userid matches the item being accessed.''' | |
| 1872 return userid == itemid | |
| 1873 p = db.security.addPermission(name='View', klass='user', check=own_record, | |
| 1874 description="User is allowed to view their own user details") | |
| 1875 db.security.addPermissionToRole('User', p) | |
| 1876 p = db.security.addPermission(name='Edit', klass='user', check=own_record, | |
| 1877 properties=('username', 'password', 'address', 'realname', 'phone', | |
| 1878 'organisation', 'alternate_addresses', 'queries', 'timezone'), | |
| 1879 description="User is allowed to edit their own user details") | |
| 1880 db.security.addPermissionToRole('User', p) | |
| 1881 | |
| 1882 # Users should be able to edit and view their own queries. They should also | |
| 1883 # be able to view any marked as not private. They should not be able to | |
| 1884 # edit others' queries, even if they're not private | |
| 1885 def view_query(db, userid, itemid): | |
| 1886 private_for = db.query.get(itemid, 'private_for') | |
| 1887 if not private_for: return True | |
| 1888 return userid == private_for | |
| 1889 def edit_query(db, userid, itemid): | |
| 1890 return userid == db.query.get(itemid, 'creator') | |
| 1891 p = db.security.addPermission(name='View', klass='query', check=view_query, | |
| 1892 description="User is allowed to view their own and public queries") | |
| 1893 db.security.addPermissionToRole('User', p) | |
| 1894 p = db.security.addPermission(name='Search', klass='query') | |
| 1895 db.security.addPermissionToRole('User', p) | |
| 1896 p = db.security.addPermission(name='Edit', klass='query', check=edit_query, | |
| 1897 description="User is allowed to edit their queries") | |
| 1898 db.security.addPermissionToRole('User', p) | |
| 1899 p = db.security.addPermission(name='Retire', klass='query', check=edit_query, | |
| 1900 description="User is allowed to retire their queries") | |
| 1901 db.security.addPermissionToRole('User', p) | |
| 1902 p = db.security.addPermission(name='Restore', klass='query', check=edit_query, | |
| 1903 description="User is allowed to restore their queries") | |
| 1904 db.security.addPermissionToRole('User', p) | |
| 1905 p = db.security.addPermission(name='Create', klass='query', | |
| 1906 description="User is allowed to create queries") | |
| 1907 db.security.addPermissionToRole('User', p) | |
| 1908 | |
| 1909 # | |
| 1910 # ANONYMOUS USER PERMISSIONS | |
| 1911 # | |
| 1912 # Let anonymous users access the web interface. Note that almost all | |
| 1913 # trackers will need this Permission. The only situation where it's not | |
| 1914 # required is in a tracker that uses an HTTP Basic Authenticated front-end. | |
| 1915 db.security.addPermissionToRole('Anonymous', 'Web Access') | |
| 1916 | |
| 1917 # Let anonymous users access the email interface (note that this implies | |
| 1918 # that they will be registered automatically, hence they will need the | |
| 1919 # "Create" user Permission below) | |
| 1920 # This is disabled by default to stop spam from auto-registering users on | |
| 1921 # public trackers. | |
| 1922 #db.security.addPermissionToRole('Anonymous', 'Email Access') | |
| 1923 | |
| 1924 # Assign the appropriate permissions to the anonymous user's Anonymous | |
| 1925 # Role. Choices here are: | |
| 1926 # - Allow anonymous users to register | |
| 1927 db.security.addPermissionToRole('Anonymous', 'Register', 'user') | |
| 1928 | |
| 1929 # Allow anonymous users access to view issues (and the related, linked | |
| 1930 # information) | |
| 1931 for cl in 'issue', 'file', 'msg', 'keyword', 'priority', 'status': | |
| 1932 db.security.addPermissionToRole('Anonymous', 'View', cl) | |
| 1933 | |
| 1934 # Allow the anonymous user to use the "Show Unassigned" search. | |
| 1935 # It acts like "Show Open" if this permission is not available. | |
| 1936 # If you are running a tracker that does not allow read access for | |
| 1937 # anonymous, you should remove this entry as it can be used to perform | |
| 1938 # a username guessing attack against a roundup install. | |
| 1939 p = db.security.addPermission(name='Search', klass='user') | |
| 1940 db.security.addPermissionToRole ('Anonymous', p) | |
| 1941 | |
| 1942 # [OPTIONAL] | |
| 1943 # Allow anonymous users access to create or edit "issue" items (and the | |
| 1944 # related file and message items) | |
| 1945 #for cl in 'issue', 'file', 'msg': | |
| 1946 # db.security.addPermissionToRole('Anonymous', 'Create', cl) | |
| 1947 # db.security.addPermissionToRole('Anonymous', 'Edit', cl) | |
| 1948 | |
| 1949 .. index:: | |
| 1950 single: roundup-admin; view class permissions | |
| 1951 | |
| 1952 You can use ``roundup-admin security`` to verify the permissions | |
| 1953 defined in the schema. It also verifies that properties specified in | |
| 1954 permissions are valid for the class. This helps detect typos that can | |
| 1955 cause baffling permission issues. | |
| 1956 | |
| 1957 Automatic Permission Checks | |
| 1958 --------------------------- | |
| 1959 | |
| 1960 Permissions are automatically checked when information is rendered | |
| 1961 through the web. This includes: | |
| 1962 | |
| 1963 1. View checks for properties when being rendered via the ``plain()`` or | |
| 1964 similar methods. If the check fails, the text "[hidden]" will be | |
| 1965 displayed. | |
| 1966 2. Edit checks for properties when the edit field is being rendered via | |
| 1967 the ``field()`` or similar methods. If the check fails, the property | |
| 1968 will be rendered via the ``plain()`` method (see point 1. for subsequent | |
| 1969 checking performed) | |
| 1970 3. View checks are performed in index pages for each item being displayed | |
| 1971 such that if the user does not have permission, the row is not rendered. | |
| 1972 4. View checks are performed at the top of item pages for the Item being | |
| 1973 displayed. If the user does not have permission, the text "You are not | |
| 1974 allowed to view this page." will be displayed. | |
| 1975 5. View checks are performed at the top of index pages for the Class being | |
| 1976 displayed. If the user does not have permission, the text "You are not | |
| 1977 allowed to view this page." will be displayed. | |
| 1978 | |
| 1979 | |
| 1980 New User Roles | |
| 1981 -------------- | |
| 1982 | |
| 1983 New users are assigned the Roles defined in the config file as: | |
| 1984 | |
| 1985 - NEW_WEB_USER_ROLES | |
| 1986 - NEW_EMAIL_USER_ROLES | |
| 1987 | |
| 1988 The `users may only edit their issues`_ example shows customisation of | |
| 1989 these parameters. | |
| 1990 | |
| 1991 | |
| 1992 Changing Access Controls | |
| 1993 ------------------------ | |
| 1994 | |
| 1995 You may alter the configuration variables to change the Role that new | |
| 1996 web or email users get, for example to not give them access to the web | |
| 1997 interface if they register through email. | |
| 1998 | |
| 1999 You may use the ``roundup-admin`` "``security``" command to display the | |
| 2000 current Role and Permission configuration in your tracker. | |
| 2001 | |
| 2002 | |
| 2003 Adding a new Permission | |
| 2004 ~~~~~~~~~~~~~~~~~~~~~~~ | |
| 2005 | |
| 2006 When adding a new Permission, you will need to: | |
| 2007 | |
| 2008 1. add it to your tracker's ``schema.py`` so it is created, using | |
| 2009 ``security.addPermission``, for example:: | |
| 2010 | |
| 2011 db.security.addPermission(name="View", klass='frozzle', | |
| 2012 description="User is allowed to access frozzles") | |
| 2013 | |
| 2014 will set up a new "View" permission on the Class "frozzle". | |
| 2015 2. enable it for the Roles that should have it (verify with | |
| 2016 "``roundup-admin security``") | |
| 2017 3. add it to the relevant HTML interface templates | |
| 2018 4. add it to the appropriate xxxPermission methods on in your tracker | |
| 2019 interfaces module | |
| 2020 | |
| 2021 The ``addPermission`` method takes a few optional parameters: | |
| 2022 | |
| 2023 **properties** | |
| 2024 A sequence of property names that are the only properties to apply the | |
| 2025 new Permission to (eg. ``... klass='user', properties=('name', | |
| 2026 'email') ...``) | |
| 2027 **props_only** | |
| 2028 A boolean value (set to false by default) that is a new feature | |
| 2029 in roundup 1.6. | |
| 2030 A permission defined using: | |
| 2031 | |
| 2032 ``properties=('list', 'of', 'property', 'names')`` | |
| 2033 | |
| 2034 is used to determine access for things other than just those | |
| 2035 properties. For example a check for View permission on the issue | |
| 2036 class or an issue item can use any View permission for the issue | |
| 2037 class even if that permission has a property list. This can be | |
| 2038 confusing and surprising as you would think that a permission | |
| 2039 including properties would be used only for determining the | |
| 2040 access permission for those properties. | |
| 2041 | |
| 2042 ``roundup-admin security`` will report invalid properties for the | |
| 2043 class. For example a permission with an invalid summary property is | |
| 2044 presented as:: | |
| 2045 | |
| 2046 Allowed to see content of object regardless of spam status | |
| 2047 (View for "file": ('content', 'summary') only) | |
| 2048 | |
| 2049 **Invalid properties for file: ['summary'] | |
| 2050 | |
| 2051 Setting ``props_only=True`` will make the permission valid only for | |
| 2052 those properties. | |
| 2053 | |
| 2054 If you use a lot of permissions with property checks, it can be | |
| 2055 difficult to change all of them. Calling the function: | |
| 2056 | |
| 2057 db.security.set_props_only_default(True) | |
| 2058 | |
| 2059 at the top of ``schema.py`` will make every permission creation | |
| 2060 behave as though props_only was set to True. It is expected that the | |
| 2061 default of True will become the default in a future Roundup release. | |
| 2062 **check** | |
| 2063 A function to be executed which returns boolean determining whether | |
| 2064 the Permission is allowed. If it returns True, the permission is | |
| 2065 allowed, if it returns False the permission is denied. The function | |
| 2066 can have one of two signatures:: | |
| 2067 | |
| 2068 check(db, userid, itemid) | |
| 2069 | |
| 2070 or:: | |
| 2071 | |
| 2072 check(db, userid, itemid, **ctx) | |
| 2073 | |
| 2074 where ``db`` is a handle on the open database, ``userid`` is | |
| 2075 the user attempting access and ``itemid`` is the specific item being | |
| 2076 accessed. If the second form is used the ``ctx`` dictionary is | |
| 2077 defined with the following values:: | |
| 2078 | |
| 2079 ctx['property'] the name of the property being checked or None if | |
| 2080 it's a class check. | |
| 2081 | |
| 2082 ctx['classname'] the name of the class that is being checked | |
| 2083 (issue, query ....). | |
| 2084 | |
| 2085 ctx['permission'] the name of the permission (e.g. View, Edit...). | |
| 2086 | |
| 2087 The second form is preferred as it makes it easier to implement more | |
| 2088 complex permission schemes. An example of the use of ``ctx`` can be | |
| 2089 found in the ``upgrading.txt`` or `upgrading.html`_ document. | |
| 2090 | |
| 2091 .. _`upgrading.html`: upgrading.html | |
| 2092 | |
| 2093 Example Scenarios | |
| 2094 ~~~~~~~~~~~~~~~~~ | |
| 2095 | |
| 2096 See the `examples`_ section for longer examples of customisation. | |
| 2097 | |
| 2098 **anonymous access through the e-mail gateway** | |
| 2099 Give the "anonymous" user the "Email Access", ("Edit", "issue") and | |
| 2100 ("Create", "msg") Permissions but do not not give them the ("Create", | |
| 2101 "user") Permission. This means that when an unknown user sends email | |
| 2102 into the tracker, they're automatically logged in as "anonymous". | |
| 2103 Since they don't have the ("Create", "user") Permission, they won't | |
| 2104 be automatically registered, but since "anonymous" has permission to | |
| 2105 use the gateway, they'll still be able to submit issues. Note that | |
| 2106 the Sender information - their email address - will not be available | |
| 2107 - they're *anonymous*. | |
| 2108 | |
| 2109 **automatic registration of users in the e-mail gateway** | |
| 2110 By giving the "anonymous" user the ("Register", "user") Permission, any | |
| 2111 unidentified user will automatically be registered with the tracker | |
| 2112 (with no password, so they won't be able to log in through | |
| 2113 the web until an admin sets their password). By default new Roundup | |
| 2114 trackers don't allow this as it opens them up to spam. It may be enabled | |
| 2115 by uncommenting the appropriate addPermissionToRole in your tracker's | |
| 2116 ``schema.py`` file. The new user is given the Roles list defined in the | |
| 2117 "new_email_user_roles" config variable. | |
| 2118 | |
| 2119 **only developers may be assigned issues** | |
| 2120 Create a new Permission called "Fixer" for the "issue" class. Create a | |
| 2121 new Role "Developer" which has that Permission, and assign that to the | |
| 2122 appropriate users. Filter the list of users available in the assignedto | |
| 2123 list to include only those users. Enforce the Permission with an | |
| 2124 auditor. See the example | |
| 2125 `restricting the list of users that are assignable to a task`_. | |
| 2126 | |
| 2127 **only managers may sign off issues as complete** | |
| 2128 Create a new Permission called "Closer" for the "issue" class. Create a | |
| 2129 new Role "Manager" which has that Permission, and assign that to the | |
| 2130 appropriate users. In your web interface, only display the "resolved" | |
| 2131 issue state option when the user has the "Closer" Permissions. Enforce | |
| 2132 the Permission with an auditor. This is very similar to the previous | |
| 2133 example, except that the web interface check would look like:: | |
| 2134 | |
| 2135 <option tal:condition="python:request.user.hasPermission('Closer')" | |
| 2136 value="resolved">Resolved</option> | |
| 2137 | |
| 2138 **don't give web access to users who register through email** | |
| 2139 Create a new Role called "Email User" which has all the Permissions of | |
| 2140 the normal "User" Role minus the "Web Access" Permission. This will | |
| 2141 allow users to send in emails to the tracker, but not access the web | |
| 2142 interface. | |
| 2143 | |
| 2144 **let some users edit the details of all users** | |
| 2145 Create a new Role called "User Admin" which has the Permission for | |
| 2146 editing users:: | |
| 2147 | |
| 2148 db.security.addRole(name='User Admin', description='Managing users') | |
| 2149 p = db.security.getPermission('Edit', 'user') | |
| 2150 db.security.addPermissionToRole('User Admin', p) | |
| 2151 | |
| 2152 and assign the Role to the users who need the permission. | |
| 2153 | |
| 2154 | |
| 2155 Web Interface | |
| 2156 ============= | |
| 2157 | |
| 2158 .. contents:: | |
| 2159 :local: | |
| 2160 | |
| 2161 The web interface is provided by the ``roundup.cgi.client`` module and | |
| 2162 is used by ``roundup.cgi``, ``roundup-server`` and ``ZRoundup`` | |
| 2163 (``ZRoundup`` is broken, until further notice). In all cases, we | |
| 2164 determine which tracker is being accessed (the first part of the URL | |
| 2165 path inside the scope of the CGI handler) and pass control on to the | |
| 2166 ``roundup.cgi.client.Client`` class - which handles the rest of the | |
| 2167 access through its ``main()`` method. This means that you can do pretty | |
| 2168 much anything you want as a web interface to your tracker. | |
| 2169 | |
| 2170 | |
| 2171 | |
| 2172 Repercussions of changing the tracker schema | |
| 2173 --------------------------------------------- | |
| 2174 | |
| 2175 If you choose to change the `tracker schema`_ you will need to ensure | |
| 2176 the web interface knows about it: | |
| 2177 | |
| 2178 1. Index, item and search pages for the relevant classes may need to | |
| 2179 have properties added or removed, | |
| 2180 2. The "page" template may require links to be changed, as might the | |
| 2181 "home" page's content arguments. | |
| 2182 | |
| 2183 | |
| 2184 How requests are processed | |
| 2185 -------------------------- | |
| 2186 | |
| 2187 The basic processing of a web request proceeds as follows: | |
| 2188 | |
| 2189 1. figure out who we are, defaulting to the "anonymous" user | |
| 2190 2. figure out what the request is for - we call this the "context" | |
| 2191 3. handle any requested action (item edit, search, ...) | |
| 2192 4. render the template requested by the context, resulting in HTML | |
| 2193 output | |
| 2194 | |
| 2195 In some situations, exceptions occur: | |
| 2196 | |
| 2197 - HTTP Redirect (generally raised by an action) | |
| 2198 - SendFile (generally raised by ``determine_context``) | |
| 2199 here we serve up a FileClass "content" property | |
| 2200 - SendStaticFile (generally raised by ``determine_context``) | |
| 2201 here we serve up a file from the tracker "html" directory | |
| 2202 - Unauthorised (generally raised by an action) | |
| 2203 here the action is cancelled, the request is rendered and an error | |
| 2204 message is displayed indicating that permission was not granted for | |
| 2205 the action to take place | |
| 2206 - NotFound (raised wherever it needs to be) | |
| 2207 this exception percolates up to the CGI interface that called the | |
| 2208 client | |
| 2209 | |
| 2210 | |
| 2211 Roundup URL design | |
| 2212 ------------------ | |
| 2213 | |
| 2214 Each tracker has several hardcoded URLs. These three are equivalent and | |
| 2215 lead to the main tracker page: | |
| 2216 | |
| 2217 1. ``/`` | |
| 2218 2. ``/index`` | |
| 2219 3. ``/home`` | |
| 2220 | |
| 2221 The following prefix is used to access static resources: | |
| 2222 | |
| 2223 4. ``/@@file/`` | |
| 2224 | |
| 2225 Two additional url's are used for the API's. | |
| 2226 The `REST api`_ is accessed via: | |
| 2227 | |
| 2228 5. ``/rest/`` | |
| 2229 | |
| 2230 .. _`REST api`: rest.html | |
| 2231 | |
| 2232 and the `XMLRPC api`_ is available at: | |
| 2233 | |
| 2234 6. ``/rest/`` | |
| 2235 | |
| 2236 .. _`XMLRPC api`: xmlrpc.html | |
| 2237 | |
| 2238 All other URLs depend on the classes configured in Roundup database. | |
| 2239 Each class receives two URLs - one for the class itself and another | |
| 2240 for specific items of that class. Example for class URL: | |
| 2241 | |
| 2242 7. ``/issue`` | |
| 2243 | |
| 2244 This is usually used to show listings of class items. The URL for | |
| 2245 for specific object of issue class with id 1 will look like: | |
| 2246 | |
| 2247 8. ``/issue1`` | |
| 2248 | |
| 2249 .. _strip_zeros: | |
| 2250 | |
| 2251 Note that a leading string of 0's will be stripped from the id part of | |
| 2252 the object designator in the URL. E.G. ``/issue001`` is the same as | |
| 2253 ``/issue1``. Similarly for ``/file01`` etc. However you should | |
| 2254 generate URL's without the extra zeros. | |
| 2255 | |
| 2256 Determining web context | |
| 2257 ----------------------- | |
| 2258 | |
| 2259 To determine the "context" of a request (what request is for), we look at | |
| 2260 the URL path after the tracker root and at ``@template`` request | |
| 2261 parameter. Typical URL paths look like: | |
| 2262 | |
| 2263 1. ``/tracker/issue`` | |
| 2264 2. ``/tracker/issue1`` | |
| 2265 3. ``/tracker/@@file/style.css`` | |
| 2266 4. ``/cgi-bin/roundup.cgi/tracker/file1`` | |
| 2267 5. ``/cgi-bin/roundup.cgi/tracker/file1/kitten.png`` | |
| 2268 | |
| 2269 where tracker root is ``/tracker/`` or ``/cgi-bin/roundup.cgi/tracker/`` | |
| 2270 We're looking at "issue", "issue1", "@@file/style.css", "file1" and | |
| 2271 "file1/kitten.png" in the cases above. | |
| 2272 | |
| 2273 a. with is no path we are in the "home" context. See `the "home" | |
| 2274 context`_ below for details. "index" or "home" paths may also be used | |
| 2275 to switch into "home" context. | |
| 2276 b. for paths starting with "@@file" the additional path entry ("style.css" | |
| 2277 in the example above) specifies the static file to be served | |
| 2278 from the tracker TEMPLATES directory (or STATIC_FILES, if configured). | |
| 2279 This is usually the tracker's "html" directory. Internally this works | |
| 2280 by raising SendStaticFile exception. | |
| 2281 c. if there is something in the path (as in example 1, "issue"), it | |
| 2282 identifies the tracker class to display. | |
| 2283 d. if the path is an item designator (as in examples 2 and 4, "issue1" | |
| 2284 and "file1"), then we're to display a specific item. | |
| 2285 :ref:`Note. <strip_zeros>` | |
| 2286 e. if the path starts with an item designator and is longer than one | |
| 2287 entry (as in example 5, "file1/kitten.png"), then we're assumed to be | |
| 2288 handling an item of a ``FileClass``, and the extra path information | |
| 2289 gives the filename that the client is going to label the download | |
| 2290 with (i.e. "file1/kitten.png" is nicer to download than "file1"). | |
| 2291 This raises a ``SendFile`` exception. | |
| 2292 | |
| 2293 Neither b. or e. use templates and stop before the template is | |
| 2294 determined. For other contexts the template used is specified by the | |
| 2295 ``@template`` variable, which defaults to: | |
| 2296 | |
| 2297 - only classname supplied: "index" | |
| 2298 - full item designator supplied: "item" | |
| 2299 | |
| 2300 | |
| 2301 The "home" Context | |
| 2302 ------------------ | |
| 2303 | |
| 2304 The "home" context is special because it allows you to add templated | |
| 2305 pages to your tracker that don't rely on a class or item (ie. an issues | |
| 2306 list or specific issue). | |
| 2307 | |
| 2308 Let's say you wish to add frames to control the layout of your tracker's | |
| 2309 interface. You'd probably have: | |
| 2310 | |
| 2311 - A top-level frameset page. This page probably wouldn't be templated, so | |
| 2312 it could be served as a static file (see `serving static content`_) | |
| 2313 - A sidebar frame that is templated. Let's call this page | |
| 2314 "home.navigation.html" in your tracker's "html" directory. To load that | |
| 2315 page up, you use the URL: | |
| 2316 | |
| 2317 <tracker url>/home?@template=navigation | |
| 2318 | |
| 2319 | |
| 2320 Serving static content | |
| 2321 ---------------------- | |
| 2322 | |
| 2323 See the previous section `determining web context`_ where it describes | |
| 2324 ``@@file`` paths. | |
| 2325 | |
| 2326 | |
| 2327 Performing actions in web requests | |
| 2328 ---------------------------------- | |
| 2329 | |
| 2330 When a user requests a web page, they may optionally also request for an | |
| 2331 action to take place. As described in `how requests are processed`_, the | |
| 2332 action is performed before the requested page is generated. Actions are | |
| 2333 triggered by using a ``@action`` CGI variable, where the value is one | |
| 2334 of: | |
| 2335 | |
| 2336 **login** | |
| 2337 Attempt to log a user in. | |
| 2338 | |
| 2339 **logout** | |
| 2340 Log the user out - make them "anonymous". | |
| 2341 | |
| 2342 **register** | |
| 2343 Attempt to create a new user based on the contents of the form and then | |
| 2344 log them in. | |
| 2345 | |
| 2346 **edit** | |
| 2347 Perform an edit of an item in the database. There are some `special form | |
| 2348 variables`_ you may use. Also you can set the ``__redirect_to`` form | |
| 2349 variable to the URL that should be displayed after the edit is succesfully | |
| 2350 completed. If you wanted to edit a sequence of issues, users etc. this | |
| 2351 could be used to display the next item in the sequence to the user. | |
| 2352 | |
| 2353 **new** | |
| 2354 Add a new item to the database. You may use the same `special form | |
| 2355 variables`_ as in the "edit" action. Also you can set the | |
| 2356 ``__redirect_to`` form variable to the URL that should be displayed after | |
| 2357 the new item is created. This is useful if you want to create another | |
| 2358 item rather than edit the newly created item. | |
| 2359 | |
| 2360 **retire** | |
| 2361 Retire the item in the database. | |
| 2362 | |
| 2363 **editCSV** | |
| 2364 Performs an edit of all of a class' items in one go. See also the | |
| 2365 *class*.csv templating method which generates the CSV data to be | |
| 2366 edited, and the ``'_generic.index'`` template which uses both of these | |
| 2367 features. | |
| 2368 | |
| 2369 **search** | |
| 2370 Mangle some of the form variables: | |
| 2371 | |
| 2372 - Set the form ":filter" variable based on the values of the filter | |
| 2373 variables - if they're set to anything other than "dontcare" then add | |
| 2374 them to :filter. | |
| 2375 | |
| 2376 - Also handle the ":queryname" variable and save off the query to the | |
| 2377 user's query list. | |
| 2378 | |
| 2379 Each of the actions is implemented by a corresponding ``*XxxAction*`` (where | |
| 2380 "Xxx" is the name of the action) class in the ``roundup.cgi.actions`` module. | |
| 2381 These classes are registered with ``roundup.cgi.client.Client``. If you need | |
| 2382 to define new actions, you may add them there (see `defining new | |
| 2383 web actions`_). | |
| 2384 | |
| 2385 Each action class also has a ``*permission*`` method which determines whether | |
| 2386 the action is permissible given the current user. The base permission checks | |
| 2387 for each action are: | |
| 2388 | |
| 2389 **login** | |
| 2390 Determine whether the user has the "Web Access" Permission. | |
| 2391 **logout** | |
| 2392 No permission checks are made. | |
| 2393 **register** | |
| 2394 Determine whether the user has the ("Create", "user") Permission. | |
| 2395 **edit** | |
| 2396 Determine whether the user has permission to edit this item. If we're | |
| 2397 editing the "user" class, users are allowed to edit their own details - | |
| 2398 unless they try to edit the "roles" property, which requires the | |
| 2399 special Permission "Web Roles". | |
| 2400 **new** | |
| 2401 Determine whether the user has permission to create this item. No | |
| 2402 additional property checks are made. Additionally, new user items may | |
| 2403 be created if the user has the ("Create", "user") Permission. | |
| 2404 **editCSV** | |
| 2405 Determine whether the user has permission to edit this class. | |
| 2406 **search** | |
| 2407 Determine whether the user has permission to view this class. | |
| 2408 | |
| 2409 Protecting users from web application attacks | |
| 2410 --------------------------------------------- | |
| 2411 | |
| 2412 There is a class of attacks known as Cross Site Request Forgeries | |
| 2413 (CSRF). Malicious code running in the browser can making a | |
| 2414 request to roundup while you are logged into roundup. The | |
| 2415 malicious code piggy backs on your existing roundup session to | |
| 2416 make changes without your knowledge. Roundup 1.6 has support for | |
| 2417 defending against this by analyzing the | |
| 2418 | |
| 2419 * Referer, | |
| 2420 * Origin, and | |
| 2421 * Host or | |
| 2422 * X-Forwarded-Host | |
| 2423 | |
| 2424 HTTP headers. It compares the headers to the value of the web setting | |
| 2425 in the [tracker] section of the tracker's ``config.ini``. | |
| 2426 | |
| 2427 Also a per form token (also called a nonce) can be enabled for | |
| 2428 the tracker using the ``csrf_enforce_token`` option in | |
| 2429 config.ini. When enabled, roundup will validate a hidden form | |
| 2430 field called ``@csrf``. If the validation fails (or the token | |
| 2431 is used more than once) the request is rejected. The ``@csrf`` | |
| 2432 input field is added automatically by calling the ``submit`` | |
| 2433 function/path. It can also be added manually by calling | |
| 2434 anti_csrf_nonce() directly. For example: | |
| 2435 | |
| 2436 <input name="@csrf" type="hidden" | |
| 2437 tal:attributes="value python:utils.anti_csrf_nonce(lifetime=10)"> | |
| 2438 | |
| 2439 By default a nonce lifetime is 2 weeks. However the lifetime (in | |
| 2440 minutes) can be set by passing a lifetime argument as shown | |
| 2441 above. The example above makes the nonce lifetime 10 minutes. | |
| 2442 | |
| 2443 Search for @csrf in this document for more examples. There are | |
| 2444 more examples and information in ``upgrading.txt``. | |
| 2445 | |
| 2446 The token protects you because malicious code supplied by another | |
| 2447 site is unable to obtain the token. Thus many attempts they make | |
| 2448 to submit a request are rejected. | |
| 2449 | |
| 2450 The protection on the xmlrpc interface is untested, but is based | |
| 2451 on a valid header check against the roundup url and the presence | |
| 2452 of the ``X-REQUESTED-WITH`` header. Work to improve this is a | |
| 2453 future project after the 1.6 release. | |
| 2454 | |
| 2455 The enforcement levels can be modified in ``config.ini``. Refer to | |
| 2456 that file for details. | |
| 2457 | |
| 2458 Special form variables | |
| 2459 ---------------------- | |
| 2460 | |
| 2461 Item properties and their values are edited with html FORM | |
| 2462 variables and their values. You can: | |
| 2463 | |
| 2464 - Change the value of some property of the current item. | |
| 2465 - Create a new item of any class, and edit the new item's | |
| 2466 properties, | |
| 2467 - Attach newly created items to a multilink property of the | |
| 2468 current item. | |
| 2469 - Remove items from a multilink property of the current item. | |
| 2470 - Specify that some properties are required for the edit | |
| 2471 operation to be successful. | |
| 2472 - Redirect to a different page after creating a new item (new action | |
| 2473 only, not edit action). Usually you end up on the page for the | |
| 2474 created item. | |
| 2475 - Set up user interface locale. | |
| 2476 | |
| 2477 These operations will only take place if the form action (the | |
| 2478 ``@action`` variable) is "edit" or "new". | |
| 2479 | |
| 2480 In the following, <bracketed> values are variable, "@" may be | |
| 2481 either ":" or "@", and other text "required" is fixed. | |
| 2482 | |
| 2483 Two special form variables are used to specify user language preferences: | |
| 2484 | |
| 2485 ``@language`` | |
| 2486 value may be locale name or ``none``. If this variable is set to | |
| 2487 locale name, web interface language is changed to given value | |
| 2488 (provided that appropriate translation is available), the value | |
| 2489 is stored in the browser cookie and will be used for all following | |
| 2490 requests. If value is ``none`` the cookie is removed and the | |
| 2491 language is changed to the tracker default, set up in the tracker | |
| 2492 configuration or OS environment. | |
| 2493 | |
| 2494 ``@charset`` | |
| 2495 value may be character set name or ``none``. Character set name | |
| 2496 is stored in the browser cookie and sets output encoding for all | |
| 2497 HTML pages generated by Roundup. If value is ``none`` the cookie | |
| 2498 is removed and HTML output is reset to Roundup internal encoding | |
| 2499 (UTF-8). | |
| 2500 | |
| 2501 Most properties are specified as form variables: | |
| 2502 | |
| 2503 ``<propname>`` | |
| 2504 property on the current context item | |
| 2505 | |
| 2506 ``<designator>"@"<propname>`` | |
| 2507 property on the indicated item (for editing related information) | |
| 2508 | |
| 2509 Designators name a specific item of a class. | |
| 2510 | |
| 2511 ``<classname><N>`` | |
| 2512 Name an existing item of class <classname>. | |
| 2513 | |
| 2514 ``<classname>"-"<N>`` | |
| 2515 Name the <N>th new item of class <classname>. If the form | |
| 2516 submission is successful, a new item of <classname> is | |
| 2517 created. Within the submitted form, a particular | |
| 2518 designator of this form always refers to the same new | |
| 2519 item. | |
| 2520 | |
| 2521 Once we have determined the "propname", we look at it to see | |
| 2522 if it's special: | |
| 2523 | |
| 2524 ``@required`` | |
| 2525 The associated form value is a comma-separated list of | |
| 2526 property names that must be specified when the form is | |
| 2527 submitted for the edit operation to succeed. | |
| 2528 | |
| 2529 When the <designator> is missing, the properties are | |
| 2530 for the current context item. When <designator> is | |
| 2531 present, they are for the item specified by | |
| 2532 <designator>. | |
| 2533 | |
| 2534 The "@required" specifier must come before any of the | |
| 2535 properties it refers to are assigned in the form. | |
| 2536 | |
| 2537 ``@remove@<propname>=id(s)`` or ``@add@<propname>=id(s)`` | |
| 2538 The "@add@" and "@remove@" edit actions apply only to | |
| 2539 Multilink properties. The form value must be a | |
| 2540 comma-separate list of keys for the class specified by | |
| 2541 the simple form variable. The listed items are added | |
| 2542 to (respectively, removed from) the specified | |
| 2543 property. | |
| 2544 | |
| 2545 ``@link@<propname>=<designator>`` | |
| 2546 If the edit action is "@link@", the simple form | |
| 2547 variable must specify a Link or Multilink property. | |
| 2548 The form value is a comma-separated list of | |
| 2549 designators. The item corresponding to each | |
| 2550 designator is linked to the property given by simple | |
| 2551 form variable. | |
| 2552 | |
| 2553 None of the above (ie. just a simple form value) | |
| 2554 The value of the form variable is converted | |
| 2555 appropriately, depending on the type of the property. | |
| 2556 | |
| 2557 For a Link('klass') property, the form value is a | |
| 2558 single key for 'klass', where the key field is | |
| 2559 specified in schema.py. | |
| 2560 | |
| 2561 For a Multilink('klass') property, the form value is a | |
| 2562 comma-separated list of keys for 'klass', where the | |
| 2563 key field is specified in schema.py. | |
| 2564 | |
| 2565 Note that for simple-form-variables specifiying Link | |
| 2566 and Multilink properties, the linked-to class must | |
| 2567 have a key field. | |
| 2568 | |
| 2569 For a String() property specifying a filename, the | |
| 2570 file named by the form value is uploaded. This means we | |
| 2571 try to set additional properties "filename" and "type" (if | |
| 2572 they are valid for the class). Otherwise, the property | |
| 2573 is set to the form value. | |
| 2574 | |
| 2575 For Date(), Interval(), Boolean(), Integer() and Number() | |
| 2576 properties, the form value is converted to the | |
| 2577 appropriate value. | |
| 2578 | |
| 2579 Any of the form variables may be prefixed with a classname or | |
| 2580 designator. | |
| 2581 | |
| 2582 Setting the form variable: ``__redirect_to=`` to a url when | |
| 2583 @action=new redirects the user to the specified url after successfully | |
| 2584 creating the new item. This is useful if you want the user to create | |
| 2585 another item rather than edit the newly created item. Note that the | |
| 2586 url assigned to ``__redirect_to`` must be url encoded/quoted and be | |
| 2587 under the tracker's base url. If the base_url uses http, you can set | |
| 2588 the url to https. | |
| 2589 | |
| 2590 Two special form values are supported for backwards compatibility: | |
| 2591 | |
| 2592 @note | |
| 2593 This is equivalent to:: | |
| 2594 | |
| 2595 @link@messages=msg-1 | |
| 2596 msg-1@content=value | |
| 2597 | |
| 2598 which is equivalent to the html:: | |
| 2599 | |
| 2600 <textarea name="msg-1@content"></textarea> | |
| 2601 <input type="hidden" name="@link@messages" value="msg-1"> | |
| 2602 | |
| 2603 except that in addition, the "author" and "date" properties of | |
| 2604 "msg-1" are set to the userid of the submitter, and the current | |
| 2605 time, respectively. | |
| 2606 | |
| 2607 @file | |
| 2608 This is equivalent to:: | |
| 2609 | |
| 2610 @link@files=file-1 | |
| 2611 file-1@content=value | |
| 2612 | |
| 2613 by adding the HTML:: | |
| 2614 | |
| 2615 <input type="file" name="file-1@content"> | |
| 2616 <input type="hidden" name="@link@files" value="file-1"> | |
| 2617 | |
| 2618 The String content value is handled as described above for file | |
| 2619 uploads. | |
| 2620 | |
| 2621 If both the "@note" and "@file" form variables are | |
| 2622 specified, the action:: | |
| 2623 | |
| 2624 msg-1@link@files=file-1 | |
| 2625 | |
| 2626 is also performed. This would be expressed in HTML with:: | |
| 2627 | |
| 2628 <input type="hidden" name="msg-1@link@files" value="file-1"> | |
| 2629 | |
| 2630 We also check that FileClass items have a "content" property with | |
| 2631 actual content, otherwise we remove them from all_props before | |
| 2632 returning. | |
| 2633 | |
| 2634 | |
| 2635 Default templates | |
| 2636 ----------------- | |
| 2637 | |
| 2638 The default templates are html4 compliant. If you wish to change them to be | |
| 2639 xhtml compliant, you'll need to change the ``html_version`` configuration | |
| 2640 variable in ``config.ini`` to ``'xhtml'`` instead of ``'html4'``. | |
| 2641 | |
| 2642 Most customisation of the web view can be done by modifying the | |
| 2643 templates in the tracker ``'html'`` directory. There are several types | |
| 2644 of files in there. The *minimal* template includes: | |
| 2645 | |
| 2646 **page.html** | |
| 2647 This template usually defines the overall look of your tracker. When | |
| 2648 you view an issue, it appears inside this template. When you view an | |
| 2649 index, it also appears inside this template. This template defines a | |
| 2650 macro called "icing" which is used by almost all other templates as a | |
| 2651 coating for their content, using its "content" slot. It also defines | |
| 2652 the "head_title" and "body_title" slots to allow setting of the page | |
| 2653 title. | |
| 2654 **home.html** | |
| 2655 the default page displayed when no other page is indicated by the user | |
| 2656 **home.classlist.html** | |
| 2657 a special version of the default page that lists the classes in the | |
| 2658 tracker | |
| 2659 **classname.item.html** | |
| 2660 displays an item of the *classname* class | |
| 2661 **classname.index.html** | |
| 2662 displays a list of *classname* items | |
| 2663 **classname.search.html** | |
| 2664 displays a search page for *classname* items | |
| 2665 **_generic.index.html** | |
| 2666 used to display a list of items where there is no | |
| 2667 ``*classname*.index`` available | |
| 2668 **_generic.help.html** | |
| 2669 used to display a "class help" page where there is no | |
| 2670 ``*classname*.help`` | |
| 2671 **user.register.html** | |
| 2672 a special page just for the user class, that renders the registration | |
| 2673 page | |
| 2674 **style.css** | |
| 2675 a static file that is served up as-is | |
| 2676 | |
| 2677 The *classic* template has a number of additional templates. | |
| 2678 | |
| 2679 Remember that you can create any template extension you want to, | |
| 2680 so if you just want to play around with the templating for new issues, | |
| 2681 you can copy the current "issue.item" template to "issue.test", and then | |
| 2682 access the test template using the "@template" URL argument:: | |
| 2683 | |
| 2684 http://your.tracker.example/tracker/issue?@template=test | |
| 2685 | |
| 2686 and it won't affect your users using the "issue.item" template. | |
| 2687 | |
| 2688 You can also put templates into a subdirectory of the template | |
| 2689 directory. So if you specify:: | |
| 2690 | |
| 2691 http://your.tracker.example/tracker/issue?@template=test/item | |
| 2692 | |
| 2693 you will use the template at: ``test/issue.item.html``. If that | |
| 2694 template doesn't exit it will try to use | |
| 2695 ``test/_generic.item.html``. If that template doesn't exist | |
| 2696 it will return an error. | |
| 2697 | |
| 2698 Implementing Modal Editing Using @template | |
| 2699 ------------------------------------------ | |
| 2700 | |
| 2701 Many item templates allow you to edit the item. They contain | |
| 2702 code that renders edit boxes if the user has edit permissions. | |
| 2703 Otherwise the template will just display the item information. | |
| 2704 | |
| 2705 In some cases you want to do a modal edit. The user has to take some | |
| 2706 action (click a button or follow a link) to shift from display mode to | |
| 2707 edit mode. When the changes are submitted, ending the edit mode, | |
| 2708 the user is returned to display mode. | |
| 2709 | |
| 2710 Modal workflows usually slow things down and are not implemented by | |
| 2711 default templates. However for some workflows a modal edit is useful. | |
| 2712 For example a batch edit mode that allows the user to edit a number of | |
| 2713 issues all from one form could be implemented as a modal workflow of: | |
| 2714 | |
| 2715 * search for issues to modify | |
| 2716 * switch to edit mode and change values | |
| 2717 * exit back to the results of the search | |
| 2718 | |
| 2719 To implement the modal edit, assume you have an issue.edit.html | |
| 2720 template that implements an edit form. On the display page (a version | |
| 2721 of issue.item.html modified to only display information) add a link | |
| 2722 that calls the display url, but adds ``@template=edit`` to the link. | |
| 2723 | |
| 2724 This will now display the edit page. On the edit page you want to add | |
| 2725 a hidden text field to your form named ``@template`` with the value: | |
| 2726 ``item|edit``. When the form is submitted it is validated. If the | |
| 2727 form is correct the user will see the item rendered using the item | |
| 2728 template. If there is an error (validation failed) the item will be | |
| 2729 rendered using the edit template. The edit template that is rendered | |
| 2730 will display all the changes that the user made to the form before it | |
| 2731 was submitted. The user can correct the error and resubmit the changes | |
| 2732 until the form validates. | |
| 2733 | |
| 2734 If the form failed to validate but the ``@template`` field had the | |
| 2735 value ``item`` the user would still see the error, but all of the data | |
| 2736 the user entered would be discarded. The user would have to redo all | |
| 2737 the edits again. | |
| 2738 | |
| 2739 | |
| 2740 How the templates work | |
| 2741 ---------------------- | |
| 2742 | |
| 2743 | |
| 2744 Templating engines | |
| 2745 ~~~~~~~~~~~~~~~~~~ | |
| 2746 | |
| 2747 Since version 1.4.20 Roundup supports two templating engines: | |
| 2748 | |
| 2749 * the original `Template Attribute Language`_ (TAL) engine from Zope | |
| 2750 * the standalone Chameleon templating engine. Chameleon is intended | |
| 2751 as a replacement for the original TAL engine, and supports the | |
| 2752 same syntax, but they are not 100% compatible. The major (and most | |
| 2753 likely the only) incompatibility is the default expression type being | |
| 2754 ``python:`` instead of ``path:``. See also "Incompatibilities and | |
| 2755 differences" section of `Chameleon documentation`__. | |
| 2756 | |
| 2757 Version 1.5.0 added experimental support for the `jinja2`_ templating | |
| 2758 language. You must install the `jinja2`_ module in order to use it. The | |
| 2759 ``jinja2`` template supplied with Roundup has the templates rewritten | |
| 2760 to use ``jinja2`` rather than TAL. A number of trackers are running | |
| 2761 using ``jinja2`` templating so it is considered less experimental than | |
| 2762 Chameleon templating. | |
| 2763 | |
| 2764 .. _jinja2: https://palletsprojects.com/p/jinja/ | |
| 2765 | |
| 2766 | |
| 2767 **NOTE1**: For historical reasons, examples given below assumes path | |
| 2768 expression as default expression type. With Chameleon you have to manually | |
| 2769 resolve the path expressions. A Chameleon-based, z3c.pt, that is fully | |
| 2770 compatible with the old TAL implementation, is planned to be included in a | |
| 2771 future release. | |
| 2772 | |
| 2773 **NOTE2**: As of 1.4.20 Chameleon support is highly experimental and **not** | |
| 2774 recommended for production use. | |
| 2775 | |
| 2776 .. _Chameleon: | |
| 2777 https://pypi.org/project/Chameleon/ | |
| 2778 .. _z3c.pt: | |
| 2779 https://pypi.org/project/z3c.pt/ | |
| 2780 __ https://chameleon.readthedocs.io/en/latest/reference.html?highlight=differences#incompatibilities-and-differences | |
| 2781 .. _TAL: | |
| 2782 .. _Template Attribute Language: | |
| 2783 https://pagetemplates.readthedocs.io/en/latest/history/TALSpecification14.html | |
| 2784 | |
| 2785 | |
| 2786 Basic Templating Actions | |
| 2787 ~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 2788 | |
| 2789 Roundup's templates consist of special attributes on the HTML tags. | |
| 2790 These attributes form the **Template Attribute Language**, or TAL. | |
| 2791 The basic TAL commands are: | |
| 2792 | |
| 2793 **tal:define="variable expression; variable expression; ..."** | |
| 2794 Define a new variable that is local to this tag and its contents. For | |
| 2795 example:: | |
| 2796 | |
| 2797 <html tal:define="title request/description"> | |
| 2798 <head><title tal:content="title"></title></head> | |
| 2799 </html> | |
| 2800 | |
| 2801 In this example, the variable "title" is defined as the result of the | |
| 2802 expression "request/description". The "tal:content" command inside the | |
| 2803 <html> tag may then use the "title" variable. | |
| 2804 | |
| 2805 **tal:condition="expression"** | |
| 2806 Only keep this tag and its contents if the expression is true. For | |
| 2807 example:: | |
| 2808 | |
| 2809 <p tal:condition="python:request.user.hasPermission('View', 'issue')"> | |
| 2810 Display some issue information. | |
| 2811 </p> | |
| 2812 | |
| 2813 In the example, the <p> tag and its contents are only displayed if | |
| 2814 the user has the "View" permission for issues. We consider the number | |
| 2815 zero, a blank string, an empty list, and the built-in variable | |
| 2816 nothing to be false values. Nearly every other value is true, | |
| 2817 including non-zero numbers, and strings with anything in them (even | |
| 2818 spaces!). | |
| 2819 | |
| 2820 **tal:repeat="variable expression"** | |
| 2821 Repeat this tag and its contents for each element of the sequence | |
| 2822 that the expression returns, defining a new local variable and a | |
| 2823 special "repeat" variable for each element. For example:: | |
| 2824 | |
| 2825 <tr tal:repeat="u user/list"> | |
| 2826 <td tal:content="u/id"></td> | |
| 2827 <td tal:content="u/username"></td> | |
| 2828 <td tal:content="u/realname"></td> | |
| 2829 </tr> | |
| 2830 | |
| 2831 The example would iterate over the sequence of users returned by | |
| 2832 "user/list" and define the local variable "u" for each entry. Using | |
| 2833 the repeat command creates a new variable called "repeat" which you | |
| 2834 may access to gather information about the iteration. See the section | |
| 2835 below on `the repeat variable`_. | |
| 2836 | |
| 2837 **tal:replace="expression"** | |
| 2838 Replace this tag with the result of the expression. For example:: | |
| 2839 | |
| 2840 <span tal:replace="request/user/realname" /> | |
| 2841 | |
| 2842 The example would replace the <span> tag and its contents with the | |
| 2843 user's realname. If the user's realname was "Bruce", then the | |
| 2844 resultant output would be "Bruce". | |
| 2845 | |
| 2846 **tal:content="expression"** | |
| 2847 Replace the contents of this tag with the result of the expression. | |
| 2848 For example:: | |
| 2849 | |
| 2850 <span tal:content="request/user/realname">user's name appears here | |
| 2851 </span> | |
| 2852 | |
| 2853 The example would replace the contents of the <span> tag with the | |
| 2854 user's realname. If the user's realname was "Bruce" then the | |
| 2855 resultant output would be "<span>Bruce</span>". | |
| 2856 | |
| 2857 **tal:attributes="attribute expression; attribute expression; ..."** | |
| 2858 Set attributes on this tag to the results of expressions. For | |
| 2859 example:: | |
| 2860 | |
| 2861 <a tal:attributes="href string:user${request/user/id}">My Details</a> | |
| 2862 | |
| 2863 In the example, the "href" attribute of the <a> tag is set to the | |
| 2864 value of the "string:user${request/user/id}" expression, which will | |
| 2865 be something like "user123". | |
| 2866 | |
| 2867 **tal:omit-tag="expression"** | |
| 2868 Remove this tag (but not its contents) if the expression is true. For | |
| 2869 example:: | |
| 2870 | |
| 2871 <span tal:omit-tag="python:1">Hello, world!</span> | |
| 2872 | |
| 2873 would result in output of:: | |
| 2874 | |
| 2875 Hello, world! | |
| 2876 | |
| 2877 Note that the commands on a given tag are evaulated in the order above, | |
| 2878 so *define* comes before *condition*, and so on. | |
| 2879 | |
| 2880 Additionally, you may include tags such as <tal:block>, which are | |
| 2881 removed from output. Its content is kept, but the tag itself is not (so | |
| 2882 don't go using any "tal:attributes" commands on it). This is useful for | |
| 2883 making arbitrary blocks of HTML conditional or repeatable (very handy | |
| 2884 for repeating multiple table rows, which would otherwise require an | |
| 2885 illegal tag placement to effect the repeat). | |
| 2886 | |
| 2887 | |
| 2888 Templating Expressions | |
| 2889 ~~~~~~~~~~~~~~~~~~~~~~ | |
| 2890 | |
| 2891 Templating Expressions are covered by `Template Attribute Language | |
| 2892 Expression Syntax`_, or TALES. The expressions you may use in the | |
| 2893 attribute values may be one of the following forms: | |
| 2894 | |
| 2895 **Path Expressions** - eg. ``item/status/checklist`` | |
| 2896 These are object attribute / item accesses. Roughly speaking, the | |
| 2897 path ``item/status/checklist`` is broken into parts ``item``, | |
| 2898 ``status`` and ``checklist``. The ``item`` part is the root of the | |
| 2899 expression. We then look for a ``status`` attribute on ``item``, or | |
| 2900 failing that, a ``status`` item (as in ``item['status']``). If that | |
| 2901 fails, the path expression fails. When we get to the end, the object | |
| 2902 we're left with is evaluated to get a string - if it is a method, it | |
| 2903 is called; if it is an object, it is stringified. Path expressions | |
| 2904 may have an optional ``path:`` prefix, but they are the default | |
| 2905 expression type, so it's not necessary. | |
| 2906 | |
| 2907 If an expression evaluates to ``default``, then the expression is | |
| 2908 "cancelled" - whatever HTML already exists in the template will | |
| 2909 remain (tag content in the case of ``tal:content``, attributes in the | |
| 2910 case of ``tal:attributes``). | |
| 2911 | |
| 2912 If an expression evaluates to ``nothing`` then the target of the | |
| 2913 expression is removed (tag content in the case of ``tal:content``, | |
| 2914 attributes in the case of ``tal:attributes`` and the tag itself in | |
| 2915 the case of ``tal:replace``). | |
| 2916 | |
| 2917 If an element in the path may not exist, then you can use the ``|`` | |
| 2918 operator in the expression to provide an alternative. So, the | |
| 2919 expression ``request/form/foo/value | default`` would simply leave | |
| 2920 the current HTML in place if the "foo" form variable doesn't exist. | |
| 2921 | |
| 2922 You may use the python function ``path``, as in | |
| 2923 ``path("item/status")``, to embed path expressions in Python | |
| 2924 expressions. | |
| 2925 | |
| 2926 **String Expressions** - eg. ``string:hello ${user/name}`` | |
| 2927 These expressions are simple string interpolations - though they can | |
| 2928 be just plain strings with no interpolation if you want. The | |
| 2929 expression in the ``${ ... }`` is just a path expression as above. | |
| 2930 | |
| 2931 **Python Expressions** - eg. ``python: 1+1`` | |
| 2932 These expressions give the full power of Python. All the "root level" | |
| 2933 variables are available, so ``python:item.status.checklist()`` would | |
| 2934 be equivalent to ``item/status/checklist``, assuming that | |
| 2935 ``checklist`` is a method. | |
| 2936 | |
| 2937 Modifiers: | |
| 2938 | |
| 2939 **structure** - eg. ``structure python:msg.content.plain(hyperlink=1)`` | |
| 2940 The result of expressions are normally *escaped* to be safe for HTML | |
| 2941 display (all "<", ">" and "&" are turned into special entities). The | |
| 2942 ``structure`` expression modifier turns off this escaping - the | |
| 2943 result of the expression is now assumed to be HTML, which is passed | |
| 2944 to the web browser for rendering. | |
| 2945 | |
| 2946 **not:** - eg. ``not:python:1=1`` | |
| 2947 This simply inverts the logical true/false value of another | |
| 2948 expression. | |
| 2949 | |
| 2950 .. _TALES: | |
| 2951 .. _Template Attribute Language Expression Syntax: | |
| 2952 https://pagetemplates.readthedocs.io/en/latest/history/TALESSpecification13.html | |
| 2953 | |
| 2954 | |
| 2955 Template Macros | |
| 2956 ~~~~~~~~~~~~~~~ | |
| 2957 | |
| 2958 Macros are used in Roundup to save us from repeating the same common | |
| 2959 page stuctures over and over. The most common (and probably only) macro | |
| 2960 you'll use is the "icing" macro defined in the "page" template. | |
| 2961 | |
| 2962 Macros are generated and used inside your templates using special | |
| 2963 attributes similar to the `basic templating actions`_. In this case, | |
| 2964 though, the attributes belong to the `Macro Expansion Template | |
| 2965 Attribute Language`_, or METAL. The macro commands are: | |
| 2966 | |
| 2967 **metal:define-macro="macro name"** | |
| 2968 Define that the tag and its contents are now a macro that may be | |
| 2969 inserted into other templates using the *use-macro* command. For | |
| 2970 example:: | |
| 2971 | |
| 2972 <html metal:define-macro="page"> | |
| 2973 ... | |
| 2974 </html> | |
| 2975 | |
| 2976 defines a macro called "page" using the ``<html>`` tag and its | |
| 2977 contents. Once defined, macros are stored on the template they're | |
| 2978 defined on in the ``macros`` attribute. You can access them later on | |
| 2979 through the ``templates`` variable, eg. the most common | |
| 2980 ``templates/page/macros/icing`` to access the "page" macro of the | |
| 2981 "page" template. | |
| 2982 | |
| 2983 **metal:use-macro="path expression"** | |
| 2984 Use a macro, which is identified by the path expression (see above). | |
| 2985 This will replace the current tag with the identified macro contents. | |
| 2986 For example:: | |
| 2987 | |
| 2988 <tal:block metal:use-macro="templates/page/macros/icing"> | |
| 2989 ... | |
| 2990 </tal:block> | |
| 2991 | |
| 2992 will replace the tag and its contents with the "page" macro of the | |
| 2993 "page" template. | |
| 2994 | |
| 2995 **metal:define-slot="slot name"** and **metal:fill-slot="slot name"** | |
| 2996 To define *dynamic* parts of the macro, you define "slots" which may | |
| 2997 be filled when the macro is used with a *use-macro* command. For | |
| 2998 example, the ``templates/page/macros/icing`` macro defines a slot like | |
| 2999 so:: | |
| 3000 | |
| 3001 <title metal:define-slot="head_title">title goes here</title> | |
| 3002 | |
| 3003 In your *use-macro* command, you may now use a *fill-slot* command | |
| 3004 like this:: | |
| 3005 | |
| 3006 <title metal:fill-slot="head_title">My Title</title> | |
| 3007 | |
| 3008 where the tag that fills the slot completely replaces the one defined | |
| 3009 as the slot in the macro. | |
| 3010 | |
| 3011 Note that you may not mix `METAL`_ and `TAL`_ commands on the same tag, but | |
| 3012 TAL commands may be used freely inside METAL-using tags (so your | |
| 3013 *fill-slots* tags may have all manner of TAL inside them). | |
| 3014 | |
| 3015 .. _METAL: | |
| 3016 .. _Macro Expansion Template Attribute Language: | |
| 3017 https://pagetemplates.readthedocs.io/en/latest/history/TALESSpecification13.html | |
| 3018 | |
| 3019 Information available to templates | |
| 3020 ---------------------------------- | |
| 3021 | |
| 3022 This is implemented by ``roundup.cgi.templating.RoundupPageTemplate`` | |
| 3023 | |
| 3024 The following variables are available to templates. | |
| 3025 | |
| 3026 **context** | |
| 3027 The current context. This is either None, a `hyperdb class wrapper`_ | |
| 3028 or a `hyperdb item wrapper`_ | |
| 3029 **request** | |
| 3030 Includes information about the current request, including: | |
| 3031 - the current index information (``filterspec``, ``filter`` args, | |
| 3032 ``properties``, etc) parsed out of the form. | |
| 3033 - methods for easy filterspec link generation | |
| 3034 - "form" | |
| 3035 The current CGI form information as a mapping of form argument name | |
| 3036 to value (specifically a cgi.FieldStorage) | |
| 3037 - "env" the CGI environment variables | |
| 3038 - "base" the base URL for this instance | |
| 3039 - "user" a HTMLItem instance for the current user | |
| 3040 - "language" as determined by the browser or config | |
| 3041 - "classname" the current classname (possibly None) | |
| 3042 - "template" the current template (suffix, also possibly None) | |
| 3043 **config** | |
| 3044 This variable holds all the values defined in the tracker config.ini | |
| 3045 file (eg. TRACKER_NAME, etc.) | |
| 3046 **db** | |
| 3047 The current database, used to access arbitrary database items. | |
| 3048 **templates** | |
| 3049 Access to all the tracker templates by name. Used mainly in | |
| 3050 *use-macro* commands. | |
| 3051 **utils** | |
| 3052 This variable makes available some utility functions like batching. | |
| 3053 **nothing** | |
| 3054 This is a special variable - if an expression evaluates to this, then | |
| 3055 the tag (in the case of a ``tal:replace``), its contents (in the case | |
| 3056 of ``tal:content``) or some attributes (in the case of | |
| 3057 ``tal:attributes``) will not appear in the the output. So, for | |
| 3058 example:: | |
| 3059 | |
| 3060 <span tal:attributes="class nothing">Hello, World!</span> | |
| 3061 | |
| 3062 would result in:: | |
| 3063 | |
| 3064 <span>Hello, World!</span> | |
| 3065 | |
| 3066 **default** | |
| 3067 Also a special variable - if an expression evaluates to this, then the | |
| 3068 existing HTML in the template will not be replaced or removed, it will | |
| 3069 remain. So:: | |
| 3070 | |
| 3071 <span tal:replace="default">Hello, World!</span> | |
| 3072 | |
| 3073 would result in:: | |
| 3074 | |
| 3075 <span>Hello, World!</span> | |
| 3076 | |
| 3077 **true**, **false** | |
| 3078 Boolean constants that may be used in `templating expressions`_ | |
| 3079 instead of ``python:1`` and ``python:0``. | |
| 3080 **i18n** | |
| 3081 Internationalization service, providing two string translation methods: | |
| 3082 | |
| 3083 **gettext** (*message*) | |
| 3084 Return the localized translation of message | |
| 3085 **ngettext** (*singular*, *plural*, *number*) | |
| 3086 Like ``gettext()``, but consider plural forms. If a translation | |
| 3087 is found, apply the plural formula to *number*, and return the | |
| 3088 resulting message (some languages have more than two plural forms). | |
| 3089 If no translation is found, return singular if *number* is 1; | |
| 3090 return plural otherwise. | |
| 3091 | |
| 3092 The context variable | |
| 3093 ~~~~~~~~~~~~~~~~~~~~ | |
| 3094 | |
| 3095 The *context* variable is one of three things based on the current | |
| 3096 context (see `determining web context`_ for how we figure this out): | |
| 3097 | |
| 3098 1. if we're looking at a "home" page, then it's None | |
| 3099 2. if we're looking at a specific hyperdb class, it's a | |
| 3100 `hyperdb class wrapper`_. | |
| 3101 3. if we're looking at a specific hyperdb item, it's a | |
| 3102 `hyperdb item wrapper`_. | |
| 3103 | |
| 3104 If the context is not None, we can access the properties of the class or | |
| 3105 item. The only real difference between cases 2 and 3 above are: | |
| 3106 | |
| 3107 1. the properties may have a real value behind them, and this will | |
| 3108 appear if the property is displayed through ``context/property`` or | |
| 3109 ``context/property/field``. | |
| 3110 2. the context's "id" property will be a false value in the second case, | |
| 3111 but a real, or true value in the third. Thus we can determine whether | |
| 3112 we're looking at a real item from the hyperdb by testing | |
| 3113 "context/id". | |
| 3114 | |
| 3115 Hyperdb class wrapper | |
| 3116 ::::::::::::::::::::: | |
| 3117 | |
| 3118 This is implemented by the ``roundup.cgi.templating.HTMLClass`` | |
| 3119 class. | |
| 3120 | |
| 3121 This wrapper object provides access to a hyperdb class. It is used | |
| 3122 primarily in both index view and new item views, but it's also usable | |
| 3123 anywhere else that you wish to access information about a class, or the | |
| 3124 items of a class, when you don't have a specific item of that class in | |
| 3125 mind. | |
| 3126 | |
| 3127 We allow access to properties. There will be no "id" property. The value | |
| 3128 accessed through the property will be the current value of the same name | |
| 3129 from the CGI form. | |
| 3130 | |
| 3131 There are several methods available on these wrapper objects: | |
| 3132 | |
| 3133 =========== ============================================================= | |
| 3134 Method Description | |
| 3135 =========== ============================================================= | |
| 3136 properties return a `hyperdb property wrapper`_ for all of this class's | |
| 3137 properties that are searchable by the user. You can use | |
| 3138 the argument cansearch=False to get all properties. | |
| 3139 list lists all of the active (not retired) items in the class. | |
| 3140 csv return the items of this class as a chunk of CSV text. | |
| 3141 propnames lists the names of the properties of this class. | |
| 3142 filter lists of items from this class, filtered and sorted. Two | |
| 3143 options are available for sorting: | |
| 3144 | |
| 3145 1. by the current *request* filterspec/filter/sort/group args | |
| 3146 2. by the "filterspec", "sort" and "group" keyword args. | |
| 3147 "filterspec" is ``{propname: value(s)}``. "sort" and | |
| 3148 "group" are an optionally empty list ``[(dir, prop)]`` | |
| 3149 where dir is '+', '-' or None | |
| 3150 and prop is a prop name or None. | |
| 3151 | |
| 3152 The propname in filterspec and prop in a sort/group spec | |
| 3153 may be transitive, i.e., it may contain properties of | |
| 3154 the form link.link.link.name. | |
| 3155 | |
| 3156 eg. All issues with a priority of "1" with messages added in | |
| 3157 the last week, sorted by activity date: | |
| 3158 ``issue.filter(filterspec={"priority": "1", | |
| 3159 'messages.creation' : '.-1w;'}, sort=[('activity', '+')])`` | |
| 3160 | |
| 3161 Note that when searching for Link and Multilink values, the | |
| 3162 special value '-1' searches for empty Link or Multilink | |
| 3163 values. For both, Links and Multilinks, multiple values | |
| 3164 given in a filter call are combined with 'OR' by default. | |
| 3165 For Multilinks a postfix expression syntax using negative ID | |
| 3166 numbers (as strings) as operators is supported. Each | |
| 3167 non-negative number (or '-1') is pushed on an operand stack. | |
| 3168 A negative number pops the required number of arguments from | |
| 3169 the stack, applies the operator, and pushes the result. The | |
| 3170 following operators are supported: | |
| 3171 | |
| 3172 - '-2' stands for 'NOT' and takes one argument | |
| 3173 - '-3' stands for 'AND' and takes two arguments | |
| 3174 - '-4' stands for 'OR' and takes two arguments | |
| 3175 | |
| 3176 Note that this special handling of ID arguments is applied only | |
| 3177 when a negative number smaller than -1 is encountered as an ID | |
| 3178 in the filter call. Otherwise the implicit OR default | |
| 3179 applies. | |
| 3180 Examples of using Multilink expressions would be | |
| 3181 | |
| 3182 - '1', '2', '-4', '3', '4', '-4', '-3' | |
| 3183 would search for IDs (1 or 2) and (3 or 4) | |
| 3184 - '-1' '-2' would search for all non-empty Multilinks | |
| 3185 | |
| 3186 filter_sql **Only in SQL backends** | |
| 3187 | |
| 3188 Lists the items that match the SQL provided. The SQL is a | |
| 3189 complete "select" statement. | |
| 3190 | |
| 3191 The SQL select must include the item id as the first column. | |
| 3192 | |
| 3193 This function **does not** filter out retired items, add | |
| 3194 on a where clause "__retired__ <> 1" if you don't want | |
| 3195 retired nodes. | |
| 3196 | |
| 3197 classhelp display a link to a javascript popup containing this class' | |
| 3198 "help" template. | |
| 3199 | |
| 3200 This generates a link to a popup window which displays the | |
| 3201 properties indicated by "properties" of the class named by | |
| 3202 "classname". The "properties" should be a comma-separated list | |
| 3203 (eg. 'id,name,description'). Properties defaults to all the | |
| 3204 properties of a class (excluding id, creator, created and | |
| 3205 activity). | |
| 3206 | |
| 3207 You may optionally override the "label" displayed, the "width", | |
| 3208 the "height", the number of items per page ("pagesize") and | |
| 3209 the field on which the list is sorted ("sort"). | |
| 3210 | |
| 3211 With the "filter" arg it is possible to specify a filter for | |
| 3212 which items are supposed to be displayed. It has to be of | |
| 3213 the format "<field>=<values>;<field>=<values>;...". | |
| 3214 | |
| 3215 The popup window will be resizable and scrollable. | |
| 3216 | |
| 3217 If the "property" arg is given, it's passed through to the | |
| 3218 javascript help_window function. This allows updating of a | |
| 3219 property in the calling HTML page. | |
| 3220 | |
| 3221 If the "form" arg is given, it's passed through to the | |
| 3222 javascript help_window function - it's the name of the form | |
| 3223 the "property" belongs to. | |
| 3224 | |
| 3225 submit generate a submit button (and action and @csrf hidden elements) | |
| 3226 renderWith render this class with the given template. | |
| 3227 history returns 'New node - no history' :) | |
| 3228 is_edit_ok is the user allowed to Edit the current class? | |
| 3229 is_view_ok is the user allowed to View the current class? | |
| 3230 =========== ============================================================= | |
| 3231 | |
| 3232 Note that if you have a property of the same name as one of the above | |
| 3233 methods, you'll need to access it using a python "item access" | |
| 3234 expression. For example:: | |
| 3235 | |
| 3236 python:context['list'] | |
| 3237 | |
| 3238 will access the "list" property, rather than the list method. | |
| 3239 | |
| 3240 | |
| 3241 Hyperdb item wrapper | |
| 3242 :::::::::::::::::::: | |
| 3243 | |
| 3244 This is implemented by the ``roundup.cgi.templating.HTMLItem`` | |
| 3245 class. | |
| 3246 | |
| 3247 This wrapper object provides access to a hyperdb item. | |
| 3248 | |
| 3249 We allow access to properties. There will be no "id" property. The value | |
| 3250 accessed through the property will be the current value of the same name | |
| 3251 from the CGI form. | |
| 3252 | |
| 3253 There are several methods available on these wrapper objects: | |
| 3254 | |
| 3255 =============== ======================================================== | |
| 3256 Method Description | |
| 3257 =============== ======================================================== | |
| 3258 submit generate a submit button (and action and @csrf hidden elements) | |
| 3259 journal return the journal of the current item (**not | |
| 3260 implemented**) | |
| 3261 history render the journal of the current item as | |
| 3262 HTML. By default properties marked as "quiet" (see | |
| 3263 `design documentation`_) are not shown unless the | |
| 3264 function is called with the showall=True parameter. | |
| 3265 Properties that are not Viewable to the user are not | |
| 3266 shown. | |
| 3267 renderQueryForm specific to the "query" class - render the search form | |
| 3268 for the query | |
| 3269 hasPermission specific to the "user" class - determine whether the | |
| 3270 user has a Permission. The signature is:: | |
| 3271 | |
| 3272 hasPermission(self, permission, [classname=], | |
| 3273 [property=], [itemid=]) | |
| 3274 | |
| 3275 where the classname defaults to the current context. | |
| 3276 hasRole specific to the "user" class - determine whether the | |
| 3277 user has a Role. The signature is:: | |
| 3278 | |
| 3279 hasRole(self, rolename) | |
| 3280 | |
| 3281 is_edit_ok is the user allowed to Edit the current item? | |
| 3282 is_view_ok is the user allowed to View the current item? | |
| 3283 is_retired is the item retired? | |
| 3284 download_url generate a url-quoted link for download of FileClass | |
| 3285 item contents (ie. file<id>/<name>) | |
| 3286 copy_url generate a url-quoted link for creating a copy | |
| 3287 of this item. By default, the copy will acquire | |
| 3288 all properties of the current item except for | |
| 3289 ``messages`` and ``files``. This can be overridden | |
| 3290 by passing ``exclude`` argument which contains a list | |
| 3291 (or any iterable) of property names that shall not be | |
| 3292 copied. Database-driven properties like ``id`` or | |
| 3293 ``activity`` cannot be copied. | |
| 3294 =============== ======================================================== | |
| 3295 | |
| 3296 Note that if you have a property of the same name as one of the above | |
| 3297 methods, you'll need to access it using a python "item access" | |
| 3298 expression. For example:: | |
| 3299 | |
| 3300 python:context['journal'] | |
| 3301 | |
| 3302 will access the "journal" property, rather than the journal method. | |
| 3303 | |
| 3304 | |
| 3305 Hyperdb property wrapper | |
| 3306 :::::::::::::::::::::::: | |
| 3307 | |
| 3308 This is implemented by subclasses of the | |
| 3309 ``roundup.cgi.templating.HTMLProperty`` class (``HTMLStringProperty``, | |
| 3310 ``HTMLNumberProperty``, and so on). | |
| 3311 | |
| 3312 This wrapper object provides access to a single property of a class. Its | |
| 3313 value may be either: | |
| 3314 | |
| 3315 1. if accessed through a `hyperdb item wrapper`_, then it's a value from | |
| 3316 the hyperdb | |
| 3317 2. if access through a `hyperdb class wrapper`_, then it's a value from | |
| 3318 the CGI form | |
| 3319 | |
| 3320 | |
| 3321 The property wrapper has some useful attributes: | |
| 3322 | |
| 3323 =============== ======================================================== | |
| 3324 Attribute Description | |
| 3325 =============== ======================================================== | |
| 3326 _name the name of the property | |
| 3327 _value the value of the property if any - this is the actual | |
| 3328 value retrieved from the hyperdb for this property | |
| 3329 =============== ======================================================== | |
| 3330 | |
| 3331 There are several methods available on these wrapper objects: | |
| 3332 | |
| 3333 =========== ================================================================ | |
| 3334 Method Description | |
| 3335 =========== ================================================================ | |
| 3336 plain render a "plain" representation of the property. This method | |
| 3337 may take two arguments: | |
| 3338 | |
| 3339 escape | |
| 3340 If true, escape the text so it is HTML safe (default: no). The | |
| 3341 reason this defaults to off is that text is usually escaped | |
| 3342 at a later stage by the TAL commands, unless the "structure" | |
| 3343 option is used in the template. The following ``tal:content`` | |
| 3344 expressions are all equivalent:: | |
| 3345 | |
| 3346 "structure python:msg.content.plain(escape=1)" | |
| 3347 "python:msg.content.plain()" | |
| 3348 "msg/content/plain" | |
| 3349 "msg/content" | |
| 3350 | |
| 3351 Usually you'll only want to use the escape option in a | |
| 3352 complex expression. | |
| 3353 | |
| 3354 hyperlink | |
| 3355 If true, turn URLs, email addresses and hyperdb item | |
| 3356 designators in the text into hyperlinks (default: no). Note | |
| 3357 that you'll need to use the "structure" TAL option if you | |
| 3358 want to use this ``tal:content`` expression:: | |
| 3359 | |
| 3360 "structure python:msg.content.plain(hyperlink=1)" | |
| 3361 | |
| 3362 The text is automatically HTML-escaped before the hyperlinking | |
| 3363 transformation done in the plain() method. | |
| 3364 | |
| 3365 hyperlinked The same as msg.content.plain(hyperlink=1), but nicer:: | |
| 3366 | |
| 3367 "structure msg/content/hyperlinked" | |
| 3368 | |
| 3369 field render an appropriate form edit field for the property - for | |
| 3370 most types this is a text entry box, but for Booleans it's a | |
| 3371 tri-state yes/no/neither selection. This method may take some | |
| 3372 arguments: | |
| 3373 | |
| 3374 size | |
| 3375 Sets the width in characters of the edit field | |
| 3376 | |
| 3377 format (Date properties only) | |
| 3378 Sets the format of the date in the field - uses the same | |
| 3379 format string argument as supplied to the ``pretty`` method | |
| 3380 below. | |
| 3381 | |
| 3382 popcal (Date properties only) | |
| 3383 Include the Javascript-based popup calendar for date | |
| 3384 selection. Defaults to on. | |
| 3385 | |
| 3386 stext only on String properties - render the value of the property | |
| 3387 as StructuredText (requires the StructureText module to be | |
| 3388 installed separately) | |
| 3389 multiline only on String properties - render a multiline form edit | |
| 3390 field for the property | |
| 3391 email only on String properties - render the value of the property | |
| 3392 as an obscured email address | |
| 3393 url_quote only on String properties. It quotes any characters in the | |
| 3394 string so it is safe to use in a url. E.G. a space is | |
| 3395 replaced with %20. | |
| 3396 confirm only on Password properties - render a second form edit field | |
| 3397 for the property, used for confirmation that the user typed | |
| 3398 the password correctly. Generates a field with name | |
| 3399 "name:confirm". | |
| 3400 now only on Date properties - return the current date as a new | |
| 3401 property | |
| 3402 reldate only on Date properties - render the interval between the date | |
| 3403 and now | |
| 3404 local only on Date properties - return this date as a new property | |
| 3405 with some timezone offset, for example:: | |
| 3406 | |
| 3407 python:context.creation.local(10) | |
| 3408 | |
| 3409 will render the date with a +10 hour offset. | |
| 3410 pretty Date properties - render the date as "dd Mon YYYY" (eg. "19 | |
| 3411 Mar 2004"). Takes an optional format argument, for example:: | |
| 3412 | |
| 3413 python:context.activity.pretty('%Y-%m-%d') | |
| 3414 | |
| 3415 Will format as "2004-03-19" instead. | |
| 3416 | |
| 3417 Interval properties - render the interval in a pretty | |
| 3418 format (eg. "yesterday"). The format arguments are those used | |
| 3419 in the standard ``strftime`` call (see the `Python Library | |
| 3420 Reference: time module`__) | |
| 3421 | |
| 3422 Number properties - takes a printf style format argument | |
| 3423 (default: '%0.3f') and formats the number accordingly. | |
| 3424 If the value can't be converted, '' is returned if the | |
| 3425 value is ``None`` otherwise it is converted to a string. | |
| 3426 popcal Generate a link to a popup calendar which may be used to | |
| 3427 edit the date field, for example:: | |
| 3428 | |
| 3429 <span tal:replace="structure context/due/popcal" /> | |
| 3430 | |
| 3431 you still need to include the ``field`` for the property, so | |
| 3432 typically you'd have:: | |
| 3433 | |
| 3434 <span tal:replace="structure context/due/field" /> | |
| 3435 <span tal:replace="structure context/due/popcal" /> | |
| 3436 | |
| 3437 menu only on Link and Multilink properties - render a form select | |
| 3438 list for this property. Takes a number of optional arguments | |
| 3439 | |
| 3440 size | |
| 3441 is used to limit the length of the list labels | |
| 3442 height | |
| 3443 is used to set the <select> tag's "size" attribute | |
| 3444 showid | |
| 3445 includes the item ids in the list labels | |
| 3446 additional | |
| 3447 lists properties which should be included in the label | |
| 3448 sort_on | |
| 3449 indicates the property to sort the list on as (direction, | |
| 3450 (direction, property) where direction is '+' or '-'. A | |
| 3451 single string with the direction prepended may be used. | |
| 3452 For example: ('-', 'order'), '+name'. | |
| 3453 value | |
| 3454 gives a default value to preselect in the menu | |
| 3455 | |
| 3456 The remaining keyword arguments are used as conditions for | |
| 3457 filtering the items in the list - they're passed as the | |
| 3458 "filterspec" argument to a Class.filter() call. For example:: | |
| 3459 | |
| 3460 <span tal:replace="structure context/status/menu" /> | |
| 3461 | |
| 3462 <span tal:replace="python:context.status.menu(order='+name", | |
| 3463 value='chatting', | |
| 3464 filterspec={'status': '1,2,3,4'}" /> | |
| 3465 | |
| 3466 sorted only on Multilink properties - produce a list of the linked | |
| 3467 items sorted by some property, for example:: | |
| 3468 | |
| 3469 python:context.files.sorted('creation') | |
| 3470 | |
| 3471 Will list the files by upload date. While | |
| 3472 | |
| 3473 python:context.files.sorted('creation', reverse=True) | |
| 3474 | |
| 3475 Will list the files by upload date in reverse order from | |
| 3476 the prior example. If the property can be unset, you can | |
| 3477 use the ``NoneFirst`` parameter to sort the None/Unset | |
| 3478 values at the front or the end of the list. For example:: | |
| 3479 | |
| 3480 python:context.files.sorted('creation', NoneFirst=True) | |
| 3481 | |
| 3482 will sort files by creation date with files missing a | |
| 3483 creation date at the start of the list. The default for | |
| 3484 ``NoneFirst`` is False so these files will sort at the end | |
| 3485 by default. (Note creation date is never unset, but you | |
| 3486 get the idea.) If you combine NoneFirst with | |
| 3487 ``reverse=True`` the meaning of NoneFirst is inverted: | |
| 3488 True sorts None/unset at the end and False sorts at the | |
| 3489 beginning. | |
| 3490 reverse only on Multilink properties - produce a list of the linked | |
| 3491 items in reverse order | |
| 3492 isset returns True if the property has been set to a value | |
| 3493 =========== ================================================================ | |
| 3494 | |
| 3495 __ https://docs.python.org/2/library/time.html | |
| 3496 | |
| 3497 All of the above functions perform checks for permissions required to | |
| 3498 display or edit the data they are manipulating. The simplest case is | |
| 3499 editing an issue title. Including the expression:: | |
| 3500 | |
| 3501 context/title/field | |
| 3502 | |
| 3503 Will present the user with an edit field, if they have edit permission. If | |
| 3504 not, then they will be presented with a static display if they have view | |
| 3505 permission. If they don't even have view permission, then an error message | |
| 3506 is raised, preventing the display of the page, indicating that they don't | |
| 3507 have permission to view the information. | |
| 3508 | |
| 3509 | |
| 3510 The request variable | |
| 3511 ~~~~~~~~~~~~~~~~~~~~ | |
| 3512 | |
| 3513 This is implemented by the ``roundup.cgi.templating.HTMLRequest`` | |
| 3514 class. | |
| 3515 | |
| 3516 The request variable is packed with information about the current | |
| 3517 request. | |
| 3518 | |
| 3519 .. taken from ``roundup.cgi.templating.HTMLRequest`` docstring | |
| 3520 | |
| 3521 =========== ============================================================ | |
| 3522 Variable Holds | |
| 3523 =========== ============================================================ | |
| 3524 form the CGI form as a cgi.FieldStorage | |
| 3525 env the CGI environment variables | |
| 3526 base the base URL for this tracker | |
| 3527 user a HTMLUser instance for this user | |
| 3528 classname the current classname (possibly None) | |
| 3529 template the current template (suffix, also possibly None) | |
| 3530 form the current CGI form variables in a FieldStorage | |
| 3531 =========== ============================================================ | |
| 3532 | |
| 3533 **Index page specific variables (indexing arguments)** | |
| 3534 | |
| 3535 =========== ============================================================ | |
| 3536 Variable Holds | |
| 3537 =========== ============================================================ | |
| 3538 columns dictionary of the columns to display in an index page | |
| 3539 show a convenience access to columns - request/show/colname will | |
| 3540 be true if the columns should be displayed, false otherwise | |
| 3541 sort index sort columns [(direction, column name)] | |
| 3542 group index grouping properties [(direction, column name)] | |
| 3543 filter properties to filter the index on | |
| 3544 filterspec values to filter the index on (property=value, eg | |
| 3545 ``priority=1`` or ``messages.author=42`` | |
| 3546 search_text text to perform a full-text search on for an index | |
| 3547 =========== ============================================================ | |
| 3548 | |
| 3549 There are several methods available on the request variable: | |
| 3550 | |
| 3551 =============== ======================================================== | |
| 3552 Method Description | |
| 3553 =============== ======================================================== | |
| 3554 description render a description of the request - handle for the | |
| 3555 page title | |
| 3556 indexargs_form render the current index args as form elements | |
| 3557 indexargs_url render the current index args as a URL | |
| 3558 base_javascript render some javascript that is used by other components | |
| 3559 of the templating | |
| 3560 batch run the current index args through a filter and return a | |
| 3561 list of items (see `hyperdb item wrapper`_, and | |
| 3562 `batching`_) | |
| 3563 =============== ======================================================== | |
| 3564 | |
| 3565 The form variable | |
| 3566 ::::::::::::::::: | |
| 3567 | |
| 3568 The form variable is a bit special because it's actually a python | |
| 3569 FieldStorage object. That means that you have two ways to access its | |
| 3570 contents. For example, to look up the CGI form value for the variable | |
| 3571 "name", use the path expression:: | |
| 3572 | |
| 3573 request/form/name/value | |
| 3574 | |
| 3575 or the python expression:: | |
| 3576 | |
| 3577 python:request.form['name'].value | |
| 3578 | |
| 3579 Note the "item" access used in the python case, and also note the | |
| 3580 explicit "value" attribute we have to access. That's because the form | |
| 3581 variables are stored as MiniFieldStorages. If there's more than one | |
| 3582 "name" value in the form, then the above will break since | |
| 3583 ``request/form/name`` is actually a *list* of MiniFieldStorages. So it's | |
| 3584 best to know beforehand what you're dealing with. | |
| 3585 | |
| 3586 | |
| 3587 The db variable | |
| 3588 ~~~~~~~~~~~~~~~ | |
| 3589 | |
| 3590 This is implemented by the ``roundup.cgi.templating.HTMLDatabase`` | |
| 3591 class. | |
| 3592 | |
| 3593 Allows access to all hyperdb classes as attributes of this variable. If | |
| 3594 you want access to the "user" class, for example, you would use:: | |
| 3595 | |
| 3596 db/user | |
| 3597 python:db.user | |
| 3598 | |
| 3599 Also, the current id of the current user is available as | |
| 3600 ``db.getuid()``. This isn't so useful in templates (where you have | |
| 3601 ``request/user``), but it can be useful in detectors or interfaces. | |
| 3602 | |
| 3603 The access results in a `hyperdb class wrapper`_. | |
| 3604 | |
| 3605 | |
| 3606 The templates variable | |
| 3607 ~~~~~~~~~~~~~~~~~~~~~~ | |
| 3608 | |
| 3609 This was implemented by the ``roundup.cgi.templating.Templates`` | |
| 3610 class before 1.4.20. In later versions it is the instance of appropriate | |
| 3611 template engine loader class. | |
| 3612 | |
| 3613 This variable is used to access other templates in expressions and | |
| 3614 template macros. It doesn't have any useful methods defined. The | |
| 3615 templates can be accessed using the following path expression:: | |
| 3616 | |
| 3617 templates/name | |
| 3618 | |
| 3619 or the python expression:: | |
| 3620 | |
| 3621 templates[name] | |
| 3622 | |
| 3623 where "name" is the name of the template you wish to access. The | |
| 3624 template has one useful attribute, namely "macros". To access a specific | |
| 3625 macro (called "macro_name"), use the path expression:: | |
| 3626 | |
| 3627 templates/name/macros/macro_name | |
| 3628 | |
| 3629 or the python expression:: | |
| 3630 | |
| 3631 templates[name].macros[macro_name] | |
| 3632 | |
| 3633 The repeat variable | |
| 3634 ~~~~~~~~~~~~~~~~~~~ | |
| 3635 | |
| 3636 The repeat variable holds an entry for each active iteration. That is, if | |
| 3637 you have a ``tal:repeat="user db/users"`` command, then there will be a | |
| 3638 repeat variable entry called "user". This may be accessed as either:: | |
| 3639 | |
| 3640 repeat/user | |
| 3641 python:repeat['user'] | |
| 3642 | |
| 3643 The "user" entry has a number of methods available for information: | |
| 3644 | |
| 3645 =============== ========================================================= | |
| 3646 Method Description | |
| 3647 =============== ========================================================= | |
| 3648 first True if the current item is the first in the sequence. | |
| 3649 last True if the current item is the last in the sequence. | |
| 3650 even True if the current item is an even item in the sequence. | |
| 3651 odd True if the current item is an odd item in the sequence. | |
| 3652 number Current position in the sequence, starting from 1. | |
| 3653 letter Current position in the sequence as a letter, a through | |
| 3654 z, then aa through zz, and so on. | |
| 3655 Letter Same as letter(), except uppercase. | |
| 3656 roman Current position in the sequence as lowercase roman | |
| 3657 numerals. | |
| 3658 Roman Same as roman(), except uppercase. | |
| 3659 =============== ========================================================= | |
| 3660 | |
| 3661 | |
| 3662 The utils variable | |
| 3663 ~~~~~~~~~~~~~~~~~~ | |
| 3664 .. _templating utilities: | |
| 3665 | |
| 3666 This is implemented by the | |
| 3667 ``roundup.cgi.templating.TemplatingUtils`` class, which may be extended | |
| 3668 with additional methods by extensions_. | |
| 3669 | |
| 3670 =============== ======================================================== | |
| 3671 Method Description | |
| 3672 =============== ======================================================== | |
| 3673 Batch return a batch object using the supplied list | |
| 3674 url_quote quote some text as safe for a URL (ie. space, %, ...) | |
| 3675 html_quote quote some text as safe in HTML (ie. <, >, ...) | |
| 3676 html_calendar renders an HTML calendar used by the | |
| 3677 ``_generic.calendar.html`` template (itself invoked by | |
| 3678 the popupCalendar DateHTMLProperty method | |
| 3679 anti_csrf_nonce returns the random noncue generated for this session | |
| 3680 =============== ======================================================== | |
| 3681 | |
| 3682 | |
| 3683 Batching | |
| 3684 :::::::: | |
| 3685 | |
| 3686 Use Batch to turn a list of items, or item ids of a given class, into a | |
| 3687 series of batches. Its usage is:: | |
| 3688 | |
| 3689 python:utils.Batch(sequence, size, start, end=0, orphan=0, | |
| 3690 overlap=0) | |
| 3691 | |
| 3692 or, to get the current index batch:: | |
| 3693 | |
| 3694 request/batch | |
| 3695 | |
| 3696 The parameters are: | |
| 3697 | |
| 3698 ========= ============================================================== | |
| 3699 Parameter Usage | |
| 3700 ========= ============================================================== | |
| 3701 sequence a list of HTMLItems | |
| 3702 size how big to make the sequence. | |
| 3703 start where to start (0-indexed) in the sequence. | |
| 3704 end where to end (0-indexed) in the sequence. | |
| 3705 orphan if the next batch would contain less items than this value, | |
| 3706 then it is combined with this batch | |
| 3707 overlap the number of items shared between adjacent batches | |
| 3708 ========= ============================================================== | |
| 3709 | |
| 3710 All of the parameters are assigned as attributes on the batch object. In | |
| 3711 addition, it has several more attributes: | |
| 3712 | |
| 3713 =============== ======================================================== | |
| 3714 Attribute Description | |
| 3715 =============== ======================================================== | |
| 3716 start indicates the start index of the batch. *Unlike | |
| 3717 the argument, is a 1-based index (I know, lame)* | |
| 3718 first indicates the start index of the batch *as a 0-based | |
| 3719 index* | |
| 3720 length the actual number of elements in the batch | |
| 3721 sequence_length the length of the original, unbatched, sequence. | |
| 3722 =============== ======================================================== | |
| 3723 | |
| 3724 And several methods: | |
| 3725 | |
| 3726 =============== ======================================================== | |
| 3727 Method Description | |
| 3728 =============== ======================================================== | |
| 3729 previous returns a new Batch with the previous batch settings | |
| 3730 next returns a new Batch with the next batch settings | |
| 3731 propchanged detect if the named property changed on the current item | |
| 3732 when compared to the last item | |
| 3733 =============== ======================================================== | |
| 3734 | |
| 3735 An example of batching:: | |
| 3736 | |
| 3737 <table class="otherinfo"> | |
| 3738 <tr><th colspan="4" class="header">Existing Keywords</th></tr> | |
| 3739 <tr tal:define="keywords db/keyword/list" | |
| 3740 tal:repeat="start python:range(0, len(keywords), 4)"> | |
| 3741 <td tal:define="batch python:utils.Batch(keywords, 4, start)" | |
| 3742 tal:repeat="keyword batch" tal:content="keyword/name"> | |
| 3743 keyword here</td> | |
| 3744 </tr> | |
| 3745 </table> | |
| 3746 | |
| 3747 ... which will produce a table with four columns containing the items of | |
| 3748 the "keyword" class (well, their "name" anyway). | |
| 3749 | |
| 3750 | |
| 3751 Translations | |
| 3752 ~~~~~~~~~~~~ | |
| 3753 | |
| 3754 Should you wish to enable multiple languages in template content that you | |
| 3755 create you'll need to add new locale files in the tracker home under a | |
| 3756 ``locale`` directory. Use the instructions in the ``developer's guide`` to | |
| 3757 create the locale files. | |
| 3758 | |
| 3759 | |
| 3760 Displaying Properties | |
| 3761 --------------------- | |
| 3762 | |
| 3763 Properties appear in the user interface in three contexts: in indices, | |
| 3764 in editors, and as search arguments. For each type of property, there | |
| 3765 are several display possibilities. For example, in an index view, a | |
| 3766 string property may just be printed as a plain string, but in an editor | |
| 3767 view, that property may be displayed in an editable field. | |
| 3768 | |
| 3769 | |
| 3770 Index Views | |
| 3771 ----------- | |
| 3772 | |
| 3773 This is one of the class context views. It is also the default view for | |
| 3774 classes. The template used is "*classname*.index". | |
| 3775 | |
| 3776 | |
| 3777 Index View Specifiers | |
| 3778 ~~~~~~~~~~~~~~~~~~~~~ | |
| 3779 | |
| 3780 An index view specifier (URL fragment) looks like this (whitespace has | |
| 3781 been added for clarity):: | |
| 3782 | |
| 3783 /issue?status=unread,in-progress,resolved& | |
| 3784 keyword=security,ui& | |
| 3785 @group=priority,-status& | |
| 3786 @sort=-activity& | |
| 3787 @filters=status,keyword& | |
| 3788 @columns=title,status,fixer | |
| 3789 | |
| 3790 The index view is determined by two parts of the specifier: the layout | |
| 3791 part and the filter part. The layout part consists of the query | |
| 3792 parameters that begin with colons, and it determines the way that the | |
| 3793 properties of selected items are displayed. The filter part consists of | |
| 3794 all the other query parameters, and it determines the criteria by which | |
| 3795 items are selected for display. The filter part is interactively | |
| 3796 manipulated with the form widgets displayed in the filter section. The | |
| 3797 layout part is interactively manipulated by clicking on the column | |
| 3798 headings in the table. | |
| 3799 | |
| 3800 The filter part selects the union of the sets of items with values | |
| 3801 matching any specified Link properties and the intersection of the sets | |
| 3802 of items with values matching any specified Multilink properties. | |
| 3803 | |
| 3804 The example specifies an index of "issue" items. Only items with a | |
| 3805 "status" of either "unread" or "in-progress" or "resolved" are | |
| 3806 displayed, and only items with "keyword" values including both "security" | |
| 3807 and "ui" are displayed. The items are grouped by priority arranged in | |
| 3808 ascending order and in descending order by status; and within | |
| 3809 groups, sorted by activity, arranged in descending order. The filter | |
| 3810 section shows filters for the "status" and "keyword" properties, and the | |
| 3811 table includes columns for the "title", "status", and "fixer" | |
| 3812 properties. | |
| 3813 | |
| 3814 ============ ============================================================= | |
| 3815 Argument Description | |
| 3816 ============ ============================================================= | |
| 3817 @sort sort by prop name, optionally preceeded with '-' to give | |
| 3818 descending or nothing for ascending sorting. Several | |
| 3819 properties can be specified delimited with comma. | |
| 3820 Internally a search-page using several sort properties may | |
| 3821 use @sort0, @sort1 etc. with option @sortdir0, @sortdir1 | |
| 3822 etc. for the direction of sorting (a non-empty value of | |
| 3823 sortdir0 specifies reverse order). | |
| 3824 @group group by prop name, optionally preceeded with '-' or to sort | |
| 3825 in descending or nothing for ascending order. Several | |
| 3826 properties can be specified delimited with comma. | |
| 3827 Internally a search-page using several grouping properties may | |
| 3828 use @group0, @group1 etc. with option @groupdir0, @groupdir1 | |
| 3829 etc. for the direction of grouping (a non-empty value of | |
| 3830 groupdir0 specifies reverse order). | |
| 3831 @columns selects the columns that should be displayed. Default is | |
| 3832 all. | |
| 3833 @filter indicates which properties are being used in filtering. | |
| 3834 Default is none. | |
| 3835 propname selects the values the item properties given by propname must | |
| 3836 have (very basic search/filter). | |
| 3837 @search_text if supplied, performs a full-text search (message bodies, | |
| 3838 issue titles, etc) | |
| 3839 ============ ============================================================= | |
| 3840 | |
| 3841 | |
| 3842 Searching Views | |
| 3843 --------------- | |
| 3844 | |
| 3845 .. note:: | |
| 3846 if you add a new column to the ``@columns`` form variable potentials | |
| 3847 then you will need to add the column to the appropriate `index views`_ | |
| 3848 template so that it is actually displayed. | |
| 3849 | |
| 3850 This is one of the class context views. The template used is typically | |
| 3851 "*classname*.search". The form on this page should have "search" as its | |
| 3852 ``@action`` variable. The "search" action: | |
| 3853 | |
| 3854 - sets up additional filtering, as well as performing indexed text | |
| 3855 searching | |
| 3856 - sets the ``@filter`` variable correctly | |
| 3857 - saves the query off if ``@query_name`` is set. | |
| 3858 | |
| 3859 The search page should lay out any fields that you wish to allow the | |
| 3860 user to search on. If your schema contains a large number of properties, | |
| 3861 you should be wary of making all of those properties available for | |
| 3862 searching, as this can cause confusion. If the additional properties are | |
| 3863 Strings, consider having their value indexed, and then they will be | |
| 3864 searchable using the full text indexed search. This is both faster, and | |
| 3865 more useful for the end user. | |
| 3866 | |
| 3867 If the search view does specify the "search" ``@action``, then it may also | |
| 3868 provide an additional argument: | |
| 3869 | |
| 3870 ============ ============================================================= | |
| 3871 Argument Description | |
| 3872 ============ ============================================================= | |
| 3873 @query_name if supplied, the index parameters (including @search_text) | |
| 3874 will be saved off as a the query item and registered against | |
| 3875 the user's queries property. Note that the *classic* template | |
| 3876 schema has this ability, but the *minimal* template schema | |
| 3877 does not. | |
| 3878 ============ ============================================================= | |
| 3879 | |
| 3880 | |
| 3881 Item Views | |
| 3882 ---------- | |
| 3883 | |
| 3884 The basic view of a hyperdb item is provided by the "*classname*.item" | |
| 3885 template. It generally has three sections; an "editor", a "spool" and a | |
| 3886 "history" section. | |
| 3887 | |
| 3888 | |
| 3889 Editor Section | |
| 3890 ~~~~~~~~~~~~~~ | |
| 3891 | |
| 3892 The editor section is used to manipulate the item - it may be a static | |
| 3893 display if the user doesn't have permission to edit the item. | |
| 3894 | |
| 3895 Here's an example of a basic editor template (this is the default | |
| 3896 "classic" template issue item edit form - from the "issue.item.html" | |
| 3897 template):: | |
| 3898 | |
| 3899 <table class="form"> | |
| 3900 <tr> | |
| 3901 <th>Title</th> | |
| 3902 <td colspan="3" tal:content="structure python:context.title.field(size=60)">title</td> | |
| 3903 </tr> | |
| 3904 | |
| 3905 <tr> | |
| 3906 <th>Priority</th> | |
| 3907 <td tal:content="structure context/priority/menu">priority</td> | |
| 3908 <th>Status</th> | |
| 3909 <td tal:content="structure context/status/menu">status</td> | |
| 3910 </tr> | |
| 3911 | |
| 3912 <tr> | |
| 3913 <th>Superseder</th> | |
| 3914 <td> | |
| 3915 <span tal:replace="structure python:context.superseder.field(showid=1, size=20)" /> | |
| 3916 <span tal:replace="structure python:db.issue.classhelp('id,title')" /> | |
| 3917 <span tal:condition="context/superseder"> | |
| 3918 <br>View: <span tal:replace="structure python:context.superseder.link(showid=1)" /> | |
| 3919 </span> | |
| 3920 </td> | |
| 3921 <th>Nosy List</th> | |
| 3922 <td> | |
| 3923 <span tal:replace="structure context/nosy/field" /> | |
| 3924 <span tal:replace="structure python:db.user.classhelp('username,realname,address,phone')" /> | |
| 3925 </td> | |
| 3926 </tr> | |
| 3927 | |
| 3928 <tr> | |
| 3929 <th>Assigned To</th> | |
| 3930 <td tal:content="structure context/assignedto/menu"> | |
| 3931 assignedto menu | |
| 3932 </td> | |
| 3933 <td> </td> | |
| 3934 <td> </td> | |
| 3935 </tr> | |
| 3936 | |
| 3937 <tr> | |
| 3938 <th>Change Note</th> | |
| 3939 <td colspan="3"> | |
| 3940 <textarea name=":note" wrap="hard" rows="5" cols="60"></textarea> | |
| 3941 </td> | |
| 3942 </tr> | |
| 3943 | |
| 3944 <tr> | |
| 3945 <th>File</th> | |
| 3946 <td colspan="3"><input type="file" name=":file" size="40"></td> | |
| 3947 </tr> | |
| 3948 | |
| 3949 <tr> | |
| 3950 <td> </td> | |
| 3951 <td colspan="3" tal:content="structure context/submit"> | |
| 3952 submit button will go here | |
| 3953 </td> | |
| 3954 </tr> | |
| 3955 </table> | |
| 3956 | |
| 3957 | |
| 3958 When a change is submitted, the system automatically generates a message | |
| 3959 describing the changed properties. As shown in the example, the editor | |
| 3960 template can use the ":note" and ":file" fields, which are added to the | |
| 3961 standard changenote message generated by Roundup. | |
| 3962 | |
| 3963 | |
| 3964 Form values | |
| 3965 ::::::::::: | |
| 3966 | |
| 3967 We have a number of ways to pull properties out of the form in order to | |
| 3968 meet the various needs of: | |
| 3969 | |
| 3970 1. editing the current item (perhaps an issue item) | |
| 3971 2. editing information related to the current item (eg. messages or | |
| 3972 attached files) | |
| 3973 3. creating new information to be linked to the current item (eg. time | |
| 3974 spent on an issue) | |
| 3975 | |
| 3976 In the following, ``<bracketed>`` values are variable, ":" may be one of | |
| 3977 ":" or "@", and other text ("required") is fixed. | |
| 3978 | |
| 3979 Properties are specified as form variables: | |
| 3980 | |
| 3981 ``<propname>`` | |
| 3982 property on the current context item | |
| 3983 | |
| 3984 ``<designator>:<propname>`` | |
| 3985 property on the indicated item (for editing related information) | |
| 3986 | |
| 3987 ``<classname>-<N>:<propname>`` | |
| 3988 property on the Nth new item of classname (generally for creating new | |
| 3989 items to attach to the current item) | |
| 3990 | |
| 3991 Once we have determined the "propname", we check to see if it is one of | |
| 3992 the special form values: | |
| 3993 | |
| 3994 ``@required`` | |
| 3995 The named property values must be supplied or a ValueError will be | |
| 3996 raised. | |
| 3997 | |
| 3998 ``@remove@<propname>=id(s)`` | |
| 3999 The ids will be removed from the multilink property. | |
| 4000 | |
| 4001 ``:add:<propname>=id(s)`` | |
| 4002 The ids will be added to the multilink property. | |
| 4003 | |
| 4004 ``:link:<propname>=<designator>`` | |
| 4005 Used to add a link to new items created during edit. These are | |
| 4006 collected and returned in ``all_links``. This will result in an | |
| 4007 additional linking operation (either Link set or Multilink append) | |
| 4008 after the edit/create is done using ``all_props`` in ``_editnodes``. | |
| 4009 The <propname> on the current item will be set/appended the id of the | |
| 4010 newly created item of class <designator> (where <designator> must be | |
| 4011 <classname>-<N>). | |
| 4012 | |
| 4013 Any of the form variables may be prefixed with a classname or | |
| 4014 designator. | |
| 4015 | |
| 4016 Two special form values are supported for backwards compatibility: | |
| 4017 | |
| 4018 ``:note`` | |
| 4019 create a message (with content, author and date), linked to the | |
| 4020 context item. This is ALWAYS designated "msg-1". | |
| 4021 ``:file`` | |
| 4022 create a file, attached to the current item and any message created by | |
| 4023 :note. This is ALWAYS designated "file-1". | |
| 4024 | |
| 4025 | |
| 4026 Spool Section | |
| 4027 ~~~~~~~~~~~~~ | |
| 4028 | |
| 4029 The spool section lists related information like the messages and files | |
| 4030 of an issue. | |
| 4031 | |
| 4032 TODO | |
| 4033 | |
| 4034 | |
| 4035 History Section | |
| 4036 ~~~~~~~~~~~~~~~ | |
| 4037 | |
| 4038 The final section displayed is the history of the item - its database | |
| 4039 journal. This is generally generated with the template:: | |
| 4040 | |
| 4041 <tal:block tal:replace="structure context/history" /> | |
| 4042 | |
| 4043 or:: | |
| 4044 | |
| 4045 <tal:block | |
| 4046 tal:replace="structure python:context.history(showall=True)" /> | |
| 4047 | |
| 4048 if you want to show history entries for quiet properties. | |
| 4049 | |
| 4050 *To be done:* | |
| 4051 | |
| 4052 *The actual history entries of the item may be accessed for manual | |
| 4053 templating through the "journal" method of the item*:: | |
| 4054 | |
| 4055 <tal:block tal:repeat="entry context/journal"> | |
| 4056 a journal entry | |
| 4057 </tal:block> | |
| 4058 | |
| 4059 *where each journal entry is an HTMLJournalEntry.* | |
| 4060 | |
| 4061 | |
| 4062 Defining new web actions | |
| 4063 ------------------------ | |
| 4064 | |
| 4065 You may define new actions to be triggered by the ``@action`` form variable. | |
| 4066 These are added to the tracker ``extensions`` directory and registered | |
| 4067 using ``instance.registerAction``. | |
| 4068 | |
| 4069 All the existing Actions are defined in ``roundup.cgi.actions``. | |
| 4070 | |
| 4071 Adding action classes takes three steps; first you `define the new | |
| 4072 action class`_, then you `register the action class`_ with the cgi | |
| 4073 interface so it may be triggered by the ``@action`` form variable. | |
| 4074 Finally you `use the new action`_ in your HTML form. | |
| 4075 | |
| 4076 See "`setting up a "wizard" (or "druid") for controlled adding of | |
| 4077 issues`_" for an example. | |
| 4078 | |
| 4079 | |
| 4080 Define the new action class | |
| 4081 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 4082 | |
| 4083 Create a new action class in your tracker's ``extensions`` directory, for | |
| 4084 example ``myaction.py``:: | |
| 4085 | |
| 4086 from roundup.cgi.actions import Action | |
| 4087 | |
| 4088 class MyAction(Action): | |
| 4089 def handle(self): | |
| 4090 ''' Perform some action. No return value is required. | |
| 4091 ''' | |
| 4092 | |
| 4093 The *self.client* attribute is an instance of ``roundup.cgi.client.Client``. | |
| 4094 See the docstring of that class for details of what it can do. | |
| 4095 | |
| 4096 The method will typically check the ``self.form`` variable's contents. | |
| 4097 It may then: | |
| 4098 | |
| 4099 - add information to ``self.client._ok_message`` | |
| 4100 or ``self.client._error_message`` (by using ``self.client.add_ok_message`` | |
| 4101 or ``self.client.add_error_message``, respectively) | |
| 4102 - change the ``self.client.template`` variable to alter what the user will see | |
| 4103 next | |
| 4104 - raise Unauthorised, SendStaticFile, SendFile, NotFound or Redirect | |
| 4105 exceptions (import them from roundup.cgi.exceptions) | |
| 4106 | |
| 4107 | |
| 4108 Register the action class | |
| 4109 ~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 4110 | |
| 4111 The class is now written, but isn't available to the user until you register | |
| 4112 it with the following code appended to your ``myaction.py`` file:: | |
| 4113 | |
| 4114 def init(instance): | |
| 4115 instance.registerAction('myaction', myActionClass) | |
| 4116 | |
| 4117 This maps the action name "myaction" to the action class we defined. | |
| 4118 | |
| 4119 | |
| 4120 Use the new action | |
| 4121 ~~~~~~~~~~~~~~~~~~ | |
| 4122 | |
| 4123 In your HTML form, add a hidden form element like so:: | |
| 4124 | |
| 4125 <input type="hidden" name="@action" value="myaction"> | |
| 4126 | |
| 4127 where "myaction" is the name you registered in the previous step. | |
| 4128 | |
| 4129 Actions may return content to the user | |
| 4130 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 4131 | |
| 4132 Actions generally perform some database manipulation and then pass control | |
| 4133 on to the rendering of a template in the current context (see `Determining | |
| 4134 web context`_ for how that works.) Some actions will want to generate the | |
| 4135 actual content returned to the user. Action methods may return their own | |
| 4136 content string to be displayed to the user, overriding the templating step. | |
| 4137 In this situation, we assume that the content is HTML by default. You may | |
| 4138 override the content type indicated to the user by calling ``setHeader``:: | |
| 4139 | |
| 4140 self.client.setHeader('Content-Type', 'text/csv') | |
| 4141 | |
| 4142 This example indicates that the value sent back to the user is actually | |
| 4143 comma-separated value content (eg. something to be loaded into a | |
| 4144 spreadsheet or database). | |
| 4145 | |
| 4146 | |
| 4147 8-bit character set support in Web interface | |
| 4148 -------------------------------------------- | |
| 4149 | |
| 4150 The web interface uses UTF-8 default. It may be overridden in both forms | |
| 4151 and a browser cookie. | |
| 4152 | |
| 4153 - In forms, use the ``@charset`` variable. | |
| 4154 - To use the cookie override, have the ``roundup_charset`` cookie set. | |
| 4155 | |
| 4156 In both cases, the value is a valid charset name (eg. ``utf-8`` or | |
| 4157 ``kio8-r``). | |
| 4158 | |
| 4159 Inside Roundup, all strings are stored and processed in utf-8. | |
| 4160 Unfortunately, some older browsers do not work properly with | |
| 4161 utf-8-encoded pages (e.g. Netscape Navigator 4 displays wrong | |
| 4162 characters in form fields). This version allows one to change | |
| 4163 the character set for http transfers. To do so, you may add | |
| 4164 the following code to your ``page.html`` template:: | |
| 4165 | |
| 4166 <tal:block define="uri string:${request/base}${request/env/PATH_INFO}"> | |
| 4167 <a tal:attributes="href python:request.indexargs_url(uri, | |
| 4168 {'@charset':'utf-8'})">utf-8</a> | |
| 4169 <a tal:attributes="href python:request.indexargs_url(uri, | |
| 4170 {'@charset':'koi8-r'})">koi8-r</a> | |
| 4171 </tal:block> | |
| 4172 | |
| 4173 (substitute ``koi8-r`` with appropriate charset for your language). | |
| 4174 Charset preference is kept in the browser cookie ``roundup_charset``. | |
| 4175 | |
| 4176 ``meta http-equiv`` lines added to the tracker templates in version 0.6.0 | |
| 4177 should be changed to include actual character set name:: | |
| 4178 | |
| 4179 <meta http-equiv="Content-Type" | |
| 4180 tal:attributes="content string:text/html;; charset=${request/client/charset}" | |
| 4181 /> | |
| 4182 | |
| 4183 The charset is also sent in the http header. | |
| 4184 | |
| 4185 .. _CustomExamples: | |
| 4186 | |
| 4187 Examples | |
| 4188 ======== | |
| 4189 | |
| 4190 .. contents:: | |
| 4191 :local: | |
| 4192 :depth: 2 | |
| 4193 | |
| 4194 | |
| 4195 Changing what's stored in the database | |
| 4196 -------------------------------------- | |
| 4197 | |
| 4198 The following examples illustrate ways to change the information stored in | |
| 4199 the database. | |
| 4200 | |
| 4201 | |
| 4202 Adding a new field to the classic schema | |
| 4203 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 4204 | |
| 4205 This example shows how to add a simple field (a due date) to the default | |
| 4206 classic schema. It does not add any additional behaviour, such as enforcing | |
| 4207 the due date, or causing automatic actions to fire if the due date passes. | |
| 4208 | |
| 4209 You add new fields by editing the ``schema.py`` file in you tracker's home. | |
| 4210 Schema changes are automatically applied to the database on the next | |
| 4211 tracker access (note that roundup-server would need to be restarted as it | |
| 4212 caches the schema). | |
| 4213 | |
| 4214 .. index:: schema; example changes | |
| 4215 | |
| 4216 1. Modify the ``schema.py``:: | |
| 4217 | |
| 4218 issue = IssueClass(db, "issue", | |
| 4219 assignedto=Link("user"), keyword=Multilink("keyword"), | |
| 4220 priority=Link("priority"), status=Link("status"), | |
| 4221 due_date=Date()) | |
| 4222 | |
| 4223 2. Add an edit field to the ``issue.item.html`` template:: | |
| 4224 | |
| 4225 <tr> | |
| 4226 <th>Due Date</th> | |
| 4227 <td tal:content="structure context/due_date/field" /> | |
| 4228 </tr> | |
| 4229 | |
| 4230 If you want to show only the date part of due_date then do this instead:: | |
| 4231 | |
| 4232 <tr> | |
| 4233 <th>Due Date</th> | |
| 4234 <td tal:content="structure python:context.due_date.field(format='%Y-%m-%d')" /> | |
| 4235 </tr> | |
| 4236 | |
| 4237 3. Add the property to the ``issue.index.html`` page:: | |
| 4238 | |
| 4239 (in the heading row) | |
| 4240 <th tal:condition="request/show/due_date">Due Date</th> | |
| 4241 (in the data row) | |
| 4242 <td tal:condition="request/show/due_date" | |
| 4243 tal:content="i/due_date" /> | |
| 4244 | |
| 4245 If you want format control of the display of the due date you can | |
| 4246 enter the following in the data row to show only the actual due date:: | |
| 4247 | |
| 4248 <td tal:condition="request/show/due_date" | |
| 4249 tal:content="python:i.due_date.pretty('%Y-%m-%d')"> </td> | |
| 4250 | |
| 4251 4. Add the property to the ``issue.search.html`` page:: | |
| 4252 | |
| 4253 <tr tal:define="name string:due_date"> | |
| 4254 <th i18n:translate="">Due Date:</th> | |
| 4255 <td metal:use-macro="search_input"></td> | |
| 4256 <td metal:use-macro="column_input"></td> | |
| 4257 <td metal:use-macro="sort_input"></td> | |
| 4258 <td metal:use-macro="group_input"></td> | |
| 4259 </tr> | |
| 4260 | |
| 4261 5. If you wish for the due date to appear in the standard views listed | |
| 4262 in the sidebar of the web interface then you'll need to add "due_date" | |
| 4263 to the columns and columns_showall lists in your ``page.html``:: | |
| 4264 | |
| 4265 columns string:id,activity,due_date,title,creator,status; | |
| 4266 columns_showall string:id,activity,due_date,title,creator,assignedto,status; | |
| 4267 | |
| 4268 Adding a new Computed field to the schema | |
| 4269 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 4270 | |
| 4271 Computed properties are a bit different from other properties. They do | |
| 4272 not actually change the database. Computed fields are not contained in | |
| 4273 the database and can not be searched or used for sorting or | |
| 4274 grouping. (Patches to add this capability are welcome.) | |
| 4275 | |
| 4276 In this example we will add a count of the number of files attached to | |
| 4277 the issue. This could be done using an auditor (see below) to update | |
| 4278 an integer field called ``filecount``. But we will implement this in a | |
| 4279 different way. | |
| 4280 | |
| 4281 We have two changes to make: | |
| 4282 | |
| 4283 1. add a new python method to the hyperdb.Computed class. It will | |
| 4284 count the number of files attached to the issue. This method will | |
| 4285 be added in the (possibly new) interfaces.py file in the top level | |
| 4286 of the tracker directory. (See interfaces.py above for more | |
| 4287 information.) | |
| 4288 2. add a new ``filecount`` property to the issue class calling the | |
| 4289 new function. | |
| 4290 | |
| 4291 A Computed method receives three arguments when called: | |
| 4292 | |
| 4293 1. the computed object (self) | |
| 4294 2. the id of the item in the class | |
| 4295 3. the database object | |
| 4296 | |
| 4297 To add the method to the Computed class, modify the trackers | |
| 4298 interfaces.py adding:: | |
| 4299 | |
| 4300 import roundup.hyperdb as hyperdb | |
| 4301 def filecount(self, nodeid, db): | |
| 4302 return len(db.issue.get(nodeid, 'files')) | |
| 4303 setattr(hyperdb.Computed, 'filecount', filecount) | |
| 4304 | |
| 4305 Then add:: | |
| 4306 | |
| 4307 filecount = Computed(Computed.filecount), | |
| 4308 | |
| 4309 to the existing IssueClass call. | |
| 4310 | |
| 4311 Now you can retrieve the value of the ``filecount`` property and it | |
| 4312 will be computed on the fly from the existing list of attached files. | |
| 4313 | |
| 4314 This example was done with the IssueClass, but you could add a | |
| 4315 Computed property to any class. E.G.:: | |
| 4316 | |
| 4317 user = Class(db, "user", | |
| 4318 username=String(), | |
| 4319 password=Password(), | |
| 4320 address=String(), | |
| 4321 realname=String(), | |
| 4322 phone=String(), | |
| 4323 office=Computed(Computed.getOfficeFromLdap), # new prop | |
| 4324 organisation=String(), | |
| 4325 alternate_addresses=String(), | |
| 4326 queries=Multilink('query'), | |
| 4327 roles=String(), | |
| 4328 timezone=String()) | |
| 4329 | |
| 4330 where the method ``getOfficeFromLdap`` queries the local ldap server to | |
| 4331 get the current office location information. The method will be called | |
| 4332 with the Computed instance, the user id and the database object. | |
| 4333 | |
| 4334 Adding a new constrained field to the classic schema | |
| 4335 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 4336 | |
| 4337 This example shows how to add a new constrained property (i.e. a | |
| 4338 selection of distinct values) to your tracker. | |
| 4339 | |
| 4340 | |
| 4341 Introduction | |
| 4342 :::::::::::: | |
| 4343 | |
| 4344 To make the classic schema of Roundup useful as a TODO tracking system | |
| 4345 for a group of systems administrators, it needs an extra data field per | |
| 4346 issue: a category. | |
| 4347 | |
| 4348 This would let sysadmins quickly list all TODOs in their particular area | |
| 4349 of interest without having to do complex queries, and without relying on | |
| 4350 the spelling capabilities of other sysadmins (a losing proposition at | |
| 4351 best). | |
| 4352 | |
| 4353 | |
| 4354 Adding a field to the database | |
| 4355 :::::::::::::::::::::::::::::: | |
| 4356 | |
| 4357 This is the easiest part of the change. The category would just be a | |
| 4358 plain string, nothing fancy. To change what is in the database you need | |
| 4359 to add some lines to the ``schema.py`` file of your tracker instance. | |
| 4360 Under the comment:: | |
| 4361 | |
| 4362 # add any additional database schema configuration here | |
| 4363 | |
| 4364 add:: | |
| 4365 | |
| 4366 category = Class(db, "category", name=String()) | |
| 4367 category.setkey("name") | |
| 4368 | |
| 4369 Here we are setting up a chunk of the database which we are calling | |
| 4370 "category". It contains a string, which we are refering to as "name" for | |
| 4371 lack of a more imaginative title. (Since "name" is one of the properties | |
| 4372 that Roundup looks for on items if you do not set a key for them, it's | |
| 4373 probably a good idea to stick with it for new classes if at all | |
| 4374 appropriate.) Then we are setting the key of this chunk of the database | |
| 4375 to be that "name". This is equivalent to an index for database types. | |
| 4376 This also means that there can only be one category with a given name. | |
| 4377 | |
| 4378 Adding the above lines allows us to create categories, but they're not | |
| 4379 tied to the issues that we are going to be creating. It's just a list of | |
| 4380 categories off on its own, which isn't much use. We need to link it in | |
| 4381 with the issues. To do that, find the lines | |
| 4382 in ``schema.py`` which set up the "issue" class, and then add a link to | |
| 4383 the category:: | |
| 4384 | |
| 4385 issue = IssueClass(db, "issue", ... , | |
| 4386 category=Multilink("category"), ... ) | |
| 4387 | |
| 4388 The ``Multilink()`` means that each issue can have many categories. If | |
| 4389 you were adding something with a one-to-one relationship to issues (such | |
| 4390 as the "assignedto" property), use ``Link()`` instead. | |
| 4391 | |
| 4392 That is all you need to do to change the schema. The rest of the effort | |
| 4393 is fiddling around so you can actually use the new category. | |
| 4394 | |
| 4395 | |
| 4396 Populating the new category class | |
| 4397 ::::::::::::::::::::::::::::::::: | |
| 4398 | |
| 4399 If you haven't initialised the database with the | |
| 4400 "``roundup-admin initialise``" command, then you | |
| 4401 can add the following to the tracker ``initial_data.py`` | |
| 4402 under the comment:: | |
| 4403 | |
| 4404 # add any additional database creation steps here - but only if you | |
| 4405 # haven't initialised the database with the admin "initialise" command | |
| 4406 | |
| 4407 Add:: | |
| 4408 | |
| 4409 category = db.getclass('category') | |
| 4410 category.create(name="scipy") | |
| 4411 category.create(name="chaco") | |
| 4412 category.create(name="weave") | |
| 4413 | |
| 4414 .. index:: roundup-admin; create entries in class | |
| 4415 | |
| 4416 If the database has already been initalised, then you need to use the | |
| 4417 ``roundup-admin`` tool:: | |
| 4418 | |
| 4419 % roundup-admin -i <tracker home> | |
| 4420 Roundup <version> ready for input. | |
| 4421 Type "help" for help. | |
| 4422 roundup> create category name=scipy | |
| 4423 1 | |
| 4424 roundup> create category name=chaco | |
| 4425 2 | |
| 4426 roundup> create category name=weave | |
| 4427 3 | |
| 4428 roundup> exit... | |
| 4429 There are unsaved changes. Commit them (y/N)? y | |
| 4430 | |
| 4431 | |
| 4432 Setting up security on the new objects | |
| 4433 :::::::::::::::::::::::::::::::::::::: | |
| 4434 | |
| 4435 By default only the admin user can look at and change objects. This | |
| 4436 doesn't suit us, as we want any user to be able to create new categories | |
| 4437 as required, and obviously everyone needs to be able to view the | |
| 4438 categories of issues for it to be useful. | |
| 4439 | |
| 4440 We therefore need to change the security of the category objects. This | |
| 4441 is also done in ``schema.py``. | |
| 4442 | |
| 4443 There are currently two loops which set up permissions and then assign | |
| 4444 them to various roles. Simply add the new "category" to both lists:: | |
| 4445 | |
| 4446 # Assign the access and edit permissions for issue, file and message | |
| 4447 # to regular users now | |
| 4448 for cl in 'issue', 'file', 'msg', 'category': | |
| 4449 p = db.security.getPermission('View', cl) | |
| 4450 db.security.addPermissionToRole('User', 'View', cl) | |
| 4451 db.security.addPermissionToRole('User', 'Edit', cl) | |
| 4452 db.security.addPermissionToRole('User', 'Create', cl) | |
| 4453 | |
| 4454 These lines assign the "View" and "Edit" Permissions to the "User" role, | |
| 4455 so that normal users can view and edit "category" objects. | |
| 4456 | |
| 4457 This is all the work that needs to be done for the database. It will | |
| 4458 store categories, and let users view and edit them. Now on to the | |
| 4459 interface stuff. | |
| 4460 | |
| 4461 | |
| 4462 Changing the web left hand frame | |
| 4463 :::::::::::::::::::::::::::::::: | |
| 4464 | |
| 4465 We need to give the users the ability to create new categories, and the | |
| 4466 place to put the link to this functionality is in the left hand function | |
| 4467 bar, under the "Issues" area. The file that defines how this area looks | |
| 4468 is ``html/page.html``, which is what we are going to be editing next. | |
| 4469 | |
| 4470 If you look at this file you can see that it contains a lot of | |
| 4471 "classblock" sections which are chunks of HTML that will be included or | |
| 4472 excluded in the output depending on whether the condition in the | |
| 4473 classblock is met. We are going to add the category code at the end of | |
| 4474 the classblock for the *issue* class:: | |
| 4475 | |
| 4476 <p class="classblock" | |
| 4477 tal:condition="python:request.user.hasPermission('View', 'category')"> | |
| 4478 <b>Categories</b><br> | |
| 4479 <a tal:condition="python:request.user.hasPermission('Edit', 'category')" | |
| 4480 href="category?@template=item">New Category<br></a> | |
| 4481 </p> | |
| 4482 | |
| 4483 The first two lines is the classblock definition, which sets up a | |
| 4484 condition that only users who have "View" permission for the "category" | |
| 4485 object will have this section included in their output. Next comes a | |
| 4486 plain "Categories" header in bold. Everyone who can view categories will | |
| 4487 get that. | |
| 4488 | |
| 4489 Next comes the link to the editing area of categories. This link will | |
| 4490 only appear if the condition - that the user has "Edit" permissions for | |
| 4491 the "category" objects - is matched. If they do have permission then | |
| 4492 they will get a link to another page which will let the user add new | |
| 4493 categories. | |
| 4494 | |
| 4495 Note that if you have permission to *view* but not to *edit* categories, | |
| 4496 then all you will see is a "Categories" header with nothing underneath | |
| 4497 it. This is obviously not very good interface design, but will do for | |
| 4498 now. I just claim that it is so I can add more links in this section | |
| 4499 later on. However, to fix the problem you could change the condition in | |
| 4500 the classblock statement, so that only users with "Edit" permission | |
| 4501 would see the "Categories" stuff. | |
| 4502 | |
| 4503 | |
| 4504 Setting up a page to edit categories | |
| 4505 :::::::::::::::::::::::::::::::::::: | |
| 4506 | |
| 4507 We defined code in the previous section which let users with the | |
| 4508 appropriate permissions see a link to a page which would let them edit | |
| 4509 conditions. Now we have to write that page. | |
| 4510 | |
| 4511 The link was for the *item* template of the *category* object. This | |
| 4512 translates into Roundup looking for a file called ``category.item.html`` | |
| 4513 in the ``html`` tracker directory. This is the file that we are going to | |
| 4514 write now. | |
| 4515 | |
| 4516 First, we add an info tag in a comment which doesn't affect the outcome | |
| 4517 of the code at all, but is useful for debugging. If you load a page in a | |
| 4518 browser and look at the page source, you can see which sections come | |
| 4519 from which files by looking for these comments:: | |
| 4520 | |
| 4521 <!-- category.item --> | |
| 4522 | |
| 4523 Next we need to add in the METAL macro stuff so we get the normal page | |
| 4524 trappings:: | |
| 4525 | |
| 4526 <tal:block metal:use-macro="templates/page/macros/icing"> | |
| 4527 <title metal:fill-slot="head_title">Category editing</title> | |
| 4528 <td class="page-header-top" metal:fill-slot="body_title"> | |
| 4529 <h2>Category editing</h2> | |
| 4530 </td> | |
| 4531 <td class="content" metal:fill-slot="content"> | |
| 4532 | |
| 4533 Next we need to setup up a standard HTML form, which is the whole | |
| 4534 purpose of this file. We link to some handy javascript which sends the | |
| 4535 form through only once. This is to stop users hitting the send button | |
| 4536 multiple times when they are impatient and thus having the form sent | |
| 4537 multiple times:: | |
| 4538 | |
| 4539 <form method="POST" onSubmit="return submit_once()" | |
| 4540 enctype="multipart/form-data"> | |
| 4541 | |
| 4542 Next we define some code which sets up the minimum list of fields that | |
| 4543 we require the user to enter. There will be only one field - "name" - so | |
| 4544 they better put something in it, otherwise the whole form is pointless:: | |
| 4545 | |
| 4546 <input type="hidden" name="@required" value="name"> | |
| 4547 | |
| 4548 To get everything to line up properly we will put everything in a table, | |
| 4549 and put a nice big header on it so the user has an idea what is | |
| 4550 happening:: | |
| 4551 | |
| 4552 <table class="form"> | |
| 4553 <tr><th class="header" colspan="2">Category</th></tr> | |
| 4554 | |
| 4555 Next, we need the field into which the user is going to enter the new | |
| 4556 category. The ``context.name.field(size=60)`` bit tells Roundup to | |
| 4557 generate a normal HTML field of size 60, and the contents of that field | |
| 4558 will be the "name" variable of the current context (namely "category"). | |
| 4559 The upshot of this is that when the user types something in | |
| 4560 to the form, a new category will be created with that name:: | |
| 4561 | |
| 4562 <tr> | |
| 4563 <th>Name</th> | |
| 4564 <td tal:content="structure python:context.name.field(size=60)"> | |
| 4565 name</td> | |
| 4566 </tr> | |
| 4567 | |
| 4568 Then a submit button so that the user can submit the new category:: | |
| 4569 | |
| 4570 <tr> | |
| 4571 <td> </td> | |
| 4572 <td colspan="3" tal:content="structure context/submit"> | |
| 4573 submit button will go here | |
| 4574 </td> | |
| 4575 </tr> | |
| 4576 | |
| 4577 The ``context/submit`` bit generates the submit button but also | |
| 4578 generates the @action and @csrf hidden fields. The @action field is | |
| 4579 used to tell roundup how to process the form. The @csrf field provides | |
| 4580 a unique single use token to defend against CSRF attacks. (More about | |
| 4581 anti-csrf measures can be found in ``upgrading.txt``.) | |
| 4582 | |
| 4583 Finally we finish off the tags we used at the start to do the METAL | |
| 4584 stuff:: | |
| 4585 | |
| 4586 </td> | |
| 4587 </tal:block> | |
| 4588 | |
| 4589 So putting it all together, and closing the table and form we get:: | |
| 4590 | |
| 4591 <!-- category.item --> | |
| 4592 <tal:block metal:use-macro="templates/page/macros/icing"> | |
| 4593 <title metal:fill-slot="head_title">Category editing</title> | |
| 4594 <td class="page-header-top" metal:fill-slot="body_title"> | |
| 4595 <h2>Category editing</h2> | |
| 4596 </td> | |
| 4597 <td class="content" metal:fill-slot="content"> | |
| 4598 <form method="POST" onSubmit="return submit_once()" | |
| 4599 enctype="multipart/form-data"> | |
| 4600 | |
| 4601 <table class="form"> | |
| 4602 <tr><th class="header" colspan="2">Category</th></tr> | |
| 4603 | |
| 4604 <tr> | |
| 4605 <th>Name</th> | |
| 4606 <td tal:content="structure python:context.name.field(size=60)"> | |
| 4607 name</td> | |
| 4608 </tr> | |
| 4609 | |
| 4610 <tr> | |
| 4611 <td> | |
| 4612 | |
| 4613 <input type="hidden" name="@required" value="name"> | |
| 4614 </td> | |
| 4615 <td colspan="3" tal:content="structure context/submit"> | |
| 4616 submit button will go here | |
| 4617 </td> | |
| 4618 </tr> | |
| 4619 </table> | |
| 4620 </form> | |
| 4621 </td> | |
| 4622 </tal:block> | |
| 4623 | |
| 4624 This is quite a lot to just ask the user one simple question, but there | |
| 4625 is a lot of setup for basically one line (the form line) to do its work. | |
| 4626 To add another field to "category" would involve one more line (well, | |
| 4627 maybe a few extra to get the formatting correct). | |
| 4628 | |
| 4629 | |
| 4630 Adding the category to the issue | |
| 4631 :::::::::::::::::::::::::::::::: | |
| 4632 | |
| 4633 We now have the ability to create issues to our heart's content, but | |
| 4634 that is pointless unless we can assign categories to issues. Just like | |
| 4635 the ``html/category.item.html`` file was used to define how to add a new | |
| 4636 category, the ``html/issue.item.html`` is used to define how a new issue | |
| 4637 is created. | |
| 4638 | |
| 4639 Just like ``category.issue.html``, this file defines a form which has a | |
| 4640 table to lay things out. It doesn't matter where in the table we add new | |
| 4641 stuff, it is entirely up to your sense of aesthetics:: | |
| 4642 | |
| 4643 <th>Category</th> | |
| 4644 <td> | |
| 4645 <span tal:replace="structure context/category/field" /> | |
| 4646 <span tal:replace="structure python:db.category.classhelp('name', | |
| 4647 property='category', width='200')" /> | |
| 4648 </td> | |
| 4649 | |
| 4650 First, we define a nice header so that the user knows what the next | |
| 4651 section is, then the middle line does what we are most interested in. | |
| 4652 This ``context/category/field`` gets replaced by a field which contains | |
| 4653 the category in the current context (the current context being the new | |
| 4654 issue). | |
| 4655 | |
| 4656 The classhelp lines generate a link (labelled "list") to a popup window | |
| 4657 which contains the list of currently known categories. | |
| 4658 | |
| 4659 | |
| 4660 Searching on categories | |
| 4661 ::::::::::::::::::::::: | |
| 4662 | |
| 4663 Now we can add categories, and create issues with categories. The next | |
| 4664 obvious thing that we would like to be able to do, would be to search | |
| 4665 for issues based on their category, so that, for example, anyone working | |
| 4666 on the web server could look at all issues in the category "Web". | |
| 4667 | |
| 4668 If you look for "Search Issues" in the ``html/page.html`` file, you will | |
| 4669 find that it looks something like | |
| 4670 ``<a href="issue?@template=search">Search Issues</a>``. This shows us | |
| 4671 that when you click on "Search Issues" it will be looking for a | |
| 4672 ``issue.search.html`` file to display. So that is the file that we will | |
| 4673 change. | |
| 4674 | |
| 4675 If you look at this file it should begin to seem familiar, although it | |
| 4676 does use some new macros. You can add the new category search code anywhere you | |
| 4677 like within that form:: | |
| 4678 | |
| 4679 <tr tal:define="name string:category; | |
| 4680 db_klass string:category; | |
| 4681 db_content string:name;"> | |
| 4682 <th>Priority:</th> | |
| 4683 <td metal:use-macro="search_select"></td> | |
| 4684 <td metal:use-macro="column_input"></td> | |
| 4685 <td metal:use-macro="sort_input"></td> | |
| 4686 <td metal:use-macro="group_input"></td> | |
| 4687 </tr> | |
| 4688 | |
| 4689 The definitions in the ``<tr>`` opening tag are used by the macros: | |
| 4690 | |
| 4691 - ``search_select`` expands to a drop-down box with all categories using | |
| 4692 ``db_klass`` and ``db_content``. | |
| 4693 - ``column_input`` expands to a checkbox for selecting what columns | |
| 4694 should be displayed. | |
| 4695 - ``sort_input`` expands to a radio button for selecting what property | |
| 4696 should be sorted on. | |
| 4697 - ``group_input`` expands to a radio button for selecting what property | |
| 4698 should be grouped on. | |
| 4699 | |
| 4700 The category search code above would expand to the following:: | |
| 4701 | |
| 4702 <tr> | |
| 4703 <th>Category:</th> | |
| 4704 <td> | |
| 4705 <select name="category"> | |
| 4706 <option value="">don't care</option> | |
| 4707 <option value="">------------</option> | |
| 4708 <option value="1">scipy</option> | |
| 4709 <option value="2">chaco</option> | |
| 4710 <option value="3">weave</option> | |
| 4711 </select> | |
| 4712 </td> | |
| 4713 <td><input type="checkbox" name=":columns" value="category"></td> | |
| 4714 <td><input type="radio" name=":sort0" value="category"></td> | |
| 4715 <td><input type="radio" name=":group0" value="category"></td> | |
| 4716 </tr> | |
| 4717 | |
| 4718 Adding category to the default view | |
| 4719 ::::::::::::::::::::::::::::::::::: | |
| 4720 | |
| 4721 We can now add categories, add issues with categories, and search for | |
| 4722 issues based on categories. This is everything that we need to do; | |
| 4723 however, there is some more icing that we would like. I think the | |
| 4724 category of an issue is important enough that it should be displayed by | |
| 4725 default when listing all the issues. | |
| 4726 | |
| 4727 Unfortunately, this is a bit less obvious than the previous steps. The | |
| 4728 code defining how the issues look is in ``html/issue.index.html``. This | |
| 4729 is a large table with a form down at the bottom for redisplaying and so | |
| 4730 forth. | |
| 4731 | |
| 4732 Firstly we need to add an appropriate header to the start of the table:: | |
| 4733 | |
| 4734 <th tal:condition="request/show/category">Category</th> | |
| 4735 | |
| 4736 The *condition* part of this statement is to avoid displaying the | |
| 4737 Category column if the user has selected not to see it. | |
| 4738 | |
| 4739 The rest of the table is a loop which will go through every issue that | |
| 4740 matches the display criteria. The loop variable is "i" - which means | |
| 4741 that every issue gets assigned to "i" in turn. | |
| 4742 | |
| 4743 The new part of code to display the category will look like this:: | |
| 4744 | |
| 4745 <td tal:condition="request/show/category" | |
| 4746 tal:content="i/category"></td> | |
| 4747 | |
| 4748 The condition is the same as above: only display the condition when the | |
| 4749 user hasn't asked for it to be hidden. The next part is to set the | |
| 4750 content of the cell to be the category part of "i" - the current issue. | |
| 4751 | |
| 4752 Finally we have to edit ``html/page.html`` again. This time, we need to | |
| 4753 tell it that when the user clicks on "Unassigned Issues" or "All Issues", | |
| 4754 the category column should be included in the resulting list. If you | |
| 4755 scroll down the page file, you can see the links with lots of options. | |
| 4756 The option that we are interested in is the ``:columns=`` one which | |
| 4757 tells roundup which fields of the issue to display. Simply add | |
| 4758 "category" to that list and it all should work. | |
| 4759 | |
| 4760 Adding a time log to your issues | |
| 4761 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 4762 | |
| 4763 We want to log the dates and amount of time spent working on issues, and | |
| 4764 be able to give a summary of the total time spent on a particular issue. | |
| 4765 | |
| 4766 1. Add a new class to your tracker ``schema.py``:: | |
| 4767 | |
| 4768 # storage for time logging | |
| 4769 timelog = Class(db, "timelog", period=Interval()) | |
| 4770 | |
| 4771 Note that we automatically get the date of the time log entry | |
| 4772 creation through the standard property "creation". | |
| 4773 | |
| 4774 You will need to grant "Creation" permission to the users who are | |
| 4775 allowed to add timelog entries. You may do this with:: | |
| 4776 | |
| 4777 db.security.addPermissionToRole('User', 'Create', 'timelog') | |
| 4778 db.security.addPermissionToRole('User', 'View', 'timelog') | |
| 4779 | |
| 4780 If users are also able to *edit* timelog entries, then also include:: | |
| 4781 | |
| 4782 db.security.addPermissionToRole('User', 'Edit', 'timelog') | |
| 4783 | |
| 4784 .. index:: schema; example changes | |
| 4785 | |
| 4786 2. Link to the new class from your issue class (again, in | |
| 4787 ``schema.py``):: | |
| 4788 | |
| 4789 issue = IssueClass(db, "issue", | |
| 4790 assignedto=Link("user"), keyword=Multilink("keyword"), | |
| 4791 priority=Link("priority"), status=Link("status"), | |
| 4792 times=Multilink("timelog")) | |
| 4793 | |
| 4794 the "times" property is the new link to the "timelog" class. | |
| 4795 | |
| 4796 3. We'll need to let people add in times to the issue, so in the web | |
| 4797 interface we'll have a new entry field. This is a special field | |
| 4798 because unlike the other fields in the ``issue.item`` template, it | |
| 4799 affects a different item (a timelog item) and not the template's | |
| 4800 item (an issue). We have a special syntax for form fields that affect | |
| 4801 items other than the template default item (see the cgi | |
| 4802 documentation on `special form variables`_). In particular, we add a | |
| 4803 field to capture a new timelog item's period:: | |
| 4804 | |
| 4805 <tr> | |
| 4806 <th>Time Log</th> | |
| 4807 <td colspan=3><input type="text" name="timelog-1@period" /> | |
| 4808 (enter as '3y 1m 4d 2:40:02' or parts thereof) | |
| 4809 </td> | |
| 4810 </tr> | |
| 4811 | |
| 4812 and another hidden field that links that new timelog item (new | |
| 4813 because it's marked as having id "-1") to the issue item. It looks | |
| 4814 like this:: | |
| 4815 | |
| 4816 <input type="hidden" name="@link@times" value="timelog-1" /> | |
| 4817 | |
| 4818 On submission, the "-1" timelog item will be created and assigned a | |
| 4819 real item id. The "times" property of the issue will have the new id | |
| 4820 added to it. | |
| 4821 | |
| 4822 The full entry will now look like this:: | |
| 4823 | |
| 4824 <tr> | |
| 4825 <th>Time Log</th> | |
| 4826 <td colspan=3><input type="text" name="timelog-1@period" /> | |
| 4827 (enter as '3y 1m 4d 2:40:02' or parts thereof) | |
| 4828 <input type="hidden" name="@link@times" value="timelog-1" /> | |
| 4829 </td> | |
| 4830 </tr> | |
| 4831 | |
| 4832 | |
| 4833 4. We want to display a total of the timelog times that have been | |
| 4834 accumulated for an issue. To do this, we'll need to actually write | |
| 4835 some Python code, since it's beyond the scope of PageTemplates to | |
| 4836 perform such calculations. We do this by adding a module ``timespent.py`` | |
| 4837 to the ``extensions`` directory in our tracker. The contents of this | |
| 4838 file is as follows:: | |
| 4839 | |
| 4840 from roundup import date | |
| 4841 | |
| 4842 def totalTimeSpent(times): | |
| 4843 ''' Call me with a list of timelog items (which have an | |
| 4844 Interval "period" property) | |
| 4845 ''' | |
| 4846 total = date.Interval('0d') | |
| 4847 for time in times: | |
| 4848 total += time.period._value | |
| 4849 return total | |
| 4850 | |
| 4851 def init(instance): | |
| 4852 instance.registerUtil('totalTimeSpent', totalTimeSpent) | |
| 4853 | |
| 4854 We will now be able to access the ``totalTimeSpent`` function via the | |
| 4855 ``utils`` variable in our templates, as shown in the next step. | |
| 4856 | |
| 4857 5. Display the timelog for an issue:: | |
| 4858 | |
| 4859 <table class="otherinfo" tal:condition="context/times"> | |
| 4860 <tr><th colspan="3" class="header">Time Log | |
| 4861 <tal:block | |
| 4862 tal:replace="python:utils.totalTimeSpent(context.times)" /> | |
| 4863 </th></tr> | |
| 4864 <tr><th>Date</th><th>Period</th><th>Logged By</th></tr> | |
| 4865 <tr tal:repeat="time context/times"> | |
| 4866 <td tal:content="time/creation"></td> | |
| 4867 <td tal:content="time/period"></td> | |
| 4868 <td tal:content="time/creator"></td> | |
| 4869 </tr> | |
| 4870 </table> | |
| 4871 | |
| 4872 I put this just above the Messages log in my issue display. Note our | |
| 4873 use of the ``totalTimeSpent`` method which will total up the times | |
| 4874 for the issue and return a new Interval. That will be automatically | |
| 4875 displayed in the template as text like "+ 1y 2:40" (1 year, 2 hours | |
| 4876 and 40 minutes). | |
| 4877 | |
| 4878 6. If you're using a persistent web server - ``roundup-server`` or | |
| 4879 ``mod_wsgi`` for example - then you'll need to restart that to pick up | |
| 4880 the code changes. When that's done, you'll be able to use the new | |
| 4881 time logging interface. | |
| 4882 | |
| 4883 An extension of this modification attaches the timelog entries to any | |
| 4884 change message entered at the time of the timelog entry: | |
| 4885 | |
| 4886 A. Add a link to the timelog to the msg class in ``schema.py``: | |
| 4887 | |
| 4888 msg = FileClass(db, "msg", | |
| 4889 author=Link("user", do_journal='no'), | |
| 4890 recipients=Multilink("user", do_journal='no'), | |
| 4891 date=Date(), | |
| 4892 summary=String(), | |
| 4893 files=Multilink("file"), | |
| 4894 messageid=String(), | |
| 4895 inreplyto=String(), | |
| 4896 times=Multilink("timelog")) | |
| 4897 | |
| 4898 B. Add a new hidden field that links that new timelog item (new | |
| 4899 because it's marked as having id "-1") to the new message. | |
| 4900 The link is placed in ``issue.item.html`` in the same section that | |
| 4901 handles the timelog entry. | |
| 4902 | |
| 4903 It looks like this after this addition:: | |
| 4904 | |
| 4905 <tr> | |
| 4906 <th>Time Log</th> | |
| 4907 <td colspan=3><input type="text" name="timelog-1@period" /> | |
| 4908 (enter as '3y 1m 4d 2:40:02' or parts thereof) | |
| 4909 <input type="hidden" name="@link@times" value="timelog-1" /> | |
| 4910 <input type="hidden" name="msg-1@link@times" value="timelog-1" /> | |
| 4911 </td> | |
| 4912 </tr> | |
| 4913 | |
| 4914 The "times" property of the message will have the new id added to it. | |
| 4915 | |
| 4916 C. Add the timelog listing from step 5. to the ``msg.item.html`` template | |
| 4917 so that the timelog entry appears on the message view page. Note that | |
| 4918 the call to totalTimeSpent is not used here since there will only be one | |
| 4919 single timelog entry for each message. | |
| 4920 | |
| 4921 I placed it after the Date entry like this:: | |
| 4922 | |
| 4923 <tr> | |
| 4924 <th i18n:translate="">Date:</th> | |
| 4925 <td tal:content="context/date"></td> | |
| 4926 </tr> | |
| 4927 </table> | |
| 4928 | |
| 4929 <table class="otherinfo" tal:condition="context/times"> | |
| 4930 <tr><th colspan="3" class="header">Time Log</th></tr> | |
| 4931 <tr><th>Date</th><th>Period</th><th>Logged By</th></tr> | |
| 4932 <tr tal:repeat="time context/times"> | |
| 4933 <td tal:content="time/creation"></td> | |
| 4934 <td tal:content="time/period"></td> | |
| 4935 <td tal:content="time/creator"></td> | |
| 4936 </tr> | |
| 4937 </table> | |
| 4938 | |
| 4939 <table class="messages"> | |
| 4940 | |
| 4941 | |
| 4942 Tracking different types of issues | |
| 4943 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 4944 | |
| 4945 Sometimes you will want to track different types of issues - developer, | |
| 4946 customer support, systems, sales leads, etc. A single Roundup tracker is | |
| 4947 able to support multiple types of issues. This example demonstrates adding | |
| 4948 a system support issue class to a tracker. | |
| 4949 | |
| 4950 1. Figure out what information you're going to want to capture. OK, so | |
| 4951 this is obvious, but sometimes it's better to actually sit down for a | |
| 4952 while and think about the schema you're going to implement. | |
| 4953 | |
| 4954 2. Add the new issue class to your tracker's ``schema.py``. Just after the | |
| 4955 "issue" class definition, add:: | |
| 4956 | |
| 4957 # list our systems | |
| 4958 system = Class(db, "system", name=String(), order=Number()) | |
| 4959 system.setkey("name") | |
| 4960 | |
| 4961 # store issues related to those systems | |
| 4962 support = IssueClass(db, "support", | |
| 4963 assignedto=Link("user"), keyword=Multilink("keyword"), | |
| 4964 status=Link("status"), deadline=Date(), | |
| 4965 affects=Multilink("system")) | |
| 4966 | |
| 4967 3. Copy the existing ``issue.*`` (item, search and index) templates in the | |
| 4968 tracker's ``html`` to ``support.*``. Edit them so they use the properties | |
| 4969 defined in the ``support`` class. Be sure to check for hidden form | |
| 4970 variables like "required" to make sure they have the correct set of | |
| 4971 required properties. | |
| 4972 | |
| 4973 4. Edit the modules in the ``detectors``, adding lines to their ``init`` | |
| 4974 functions where appropriate. Look for ``audit`` and ``react`` registrations | |
| 4975 on the ``issue`` class, and duplicate them for ``support``. | |
| 4976 | |
| 4977 5. Create a new sidebar box for the new support class. Duplicate the | |
| 4978 existing issues one, changing the ``issue`` class name to ``support``. | |
| 4979 | |
| 4980 6. Re-start your tracker and start using the new ``support`` class. | |
| 4981 | |
| 4982 | |
| 4983 Optionally, you might want to restrict the users able to access this new | |
| 4984 class to just the users with a new "SysAdmin" Role. To do this, we add | |
| 4985 some security declarations:: | |
| 4986 | |
| 4987 db.security.addPermissionToRole('SysAdmin', 'View', 'support') | |
| 4988 db.security.addPermissionToRole('SysAdmin', 'Create', 'support') | |
| 4989 db.security.addPermissionToRole('SysAdmin', 'Edit', 'support') | |
| 4990 | |
| 4991 You would then (as an "admin" user) edit the details of the appropriate | |
| 4992 users, and add "SysAdmin" to their Roles list. | |
| 4993 | |
| 4994 Alternatively, you might want to change the Edit/View permissions granted | |
| 4995 for the ``issue`` class so that it's only available to users with the "System" | |
| 4996 or "Developer" Role, and then the new class you're adding is available to | |
| 4997 all with the "User" Role. | |
| 4998 | |
| 4999 | |
| 5000 .. _external-authentication: | |
| 5001 | |
| 5002 Using External User Databases | |
| 5003 ----------------------------- | |
| 5004 | |
| 5005 Using an external password validation source | |
| 5006 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5007 | |
| 5008 .. note:: You will need to either have an "admin" user in your external | |
| 5009 password source *or* have one of your regular users have | |
| 5010 the Admin Role assigned. If you need to assign the Role *after* | |
| 5011 making the changes below, you may use the ``roundup-admin`` | |
| 5012 program to edit a user's details. | |
| 5013 | |
| 5014 We have a centrally-managed password changing system for our users. This | |
| 5015 results in a UN*X passwd-style file that we use for verification of | |
| 5016 users. Entries in the file consist of ``name:password`` where the | |
| 5017 password is encrypted using the standard UN*X ``crypt()`` function (see | |
| 5018 the ``crypt`` module in your Python distribution). An example entry | |
| 5019 would be:: | |
| 5020 | |
| 5021 admin:aamrgyQfDFSHw | |
| 5022 | |
| 5023 Each user of Roundup must still have their information stored in the Roundup | |
| 5024 database - we just use the passwd file to check their password. To do this, we | |
| 5025 need to override the standard ``verifyPassword`` method defined in | |
| 5026 ``roundup.cgi.actions.LoginAction`` and register the new class. The | |
| 5027 following is added as ``externalpassword.py`` in the tracker ``extensions`` | |
| 5028 directory:: | |
| 5029 | |
| 5030 import os, crypt | |
| 5031 from roundup.cgi.actions import LoginAction | |
| 5032 | |
| 5033 class ExternalPasswordLoginAction(LoginAction): | |
| 5034 def verifyPassword(self, userid, password): | |
| 5035 '''Look through the file, line by line, looking for a | |
| 5036 name that matches. | |
| 5037 ''' | |
| 5038 # get the user's username | |
| 5039 username = self.db.user.get(userid, 'username') | |
| 5040 | |
| 5041 # the passwords are stored in the "passwd.txt" file in the | |
| 5042 # tracker home | |
| 5043 file = os.path.join(self.db.config.TRACKER_HOME, 'passwd.txt') | |
| 5044 | |
| 5045 # see if we can find a match | |
| 5046 for ent in [line.strip().split(':') for line in | |
| 5047 open(file).readlines()]: | |
| 5048 if ent[0] == username: | |
| 5049 return crypt.crypt(password, ent[1][:2]) == ent[1] | |
| 5050 | |
| 5051 # user doesn't exist in the file | |
| 5052 return 0 | |
| 5053 | |
| 5054 def init(instance): | |
| 5055 instance.registerAction('login', ExternalPasswordLoginAction) | |
| 5056 | |
| 5057 You should also remove the redundant password fields from the ``user.item`` | |
| 5058 template. | |
| 5059 | |
| 5060 | |
| 5061 Using a UN*X passwd file as the user database | |
| 5062 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5063 | |
| 5064 On some systems the primary store of users is the UN*X passwd file. It | |
| 5065 holds information on users such as their username, real name, password | |
| 5066 and primary user group. | |
| 5067 | |
| 5068 Roundup can use this store as its primary source of user information, | |
| 5069 but it needs additional information too - email address(es), roundup | |
| 5070 Roles, vacation flags, roundup hyperdb item ids, etc. Also, "retired" | |
| 5071 users must still exist in the user database, unlike some passwd files in | |
| 5072 which the users are removed when they no longer have access to a system. | |
| 5073 | |
| 5074 To make use of the passwd file, we therefore synchronise between the two | |
| 5075 user stores. We also use the passwd file to validate the user logins, as | |
| 5076 described in the previous example, `using an external password | |
| 5077 validation source`_. We keep the user lists in sync using a fairly | |
| 5078 simple script that runs once a day, or several times an hour if more | |
| 5079 immediate access is needed. In short, it: | |
| 5080 | |
| 5081 1. parses the passwd file, finding usernames, passwords and real names, | |
| 5082 2. compares that list to the current roundup user list: | |
| 5083 | |
| 5084 a. entries no longer in the passwd file are *retired* | |
| 5085 b. entries with mismatching real names are *updated* | |
| 5086 c. entries only exist in the passwd file are *created* | |
| 5087 | |
| 5088 3. send an email to administrators to let them know what's been done. | |
| 5089 | |
| 5090 The retiring and updating are simple operations, requiring only a call | |
| 5091 to ``retire()`` or ``set()``. The creation operation requires more | |
| 5092 information though - the user's email address and their Roundup Roles. | |
| 5093 We're going to assume that the user's email address is the same as their | |
| 5094 login name, so we just append the domain name to that. The Roles are | |
| 5095 determined using the passwd group identifier - mapping their UN*X group | |
| 5096 to an appropriate set of Roles. | |
| 5097 | |
| 5098 The script to perform all this, broken up into its main components, is | |
| 5099 as follows. Firstly, we import the necessary modules and open the | |
| 5100 tracker we're to work on:: | |
| 5101 | |
| 5102 import sys, os, smtplib | |
| 5103 from roundup import instance, date | |
| 5104 | |
| 5105 # open the tracker | |
| 5106 tracker_home = sys.argv[1] | |
| 5107 tracker = instance.open(tracker_home) | |
| 5108 | |
| 5109 Next we read in the *passwd* file from the tracker home:: | |
| 5110 | |
| 5111 # read in the users from the "passwd.txt" file | |
| 5112 file = os.path.join(tracker_home, 'passwd.txt') | |
| 5113 users = [x.strip().split(':') for x in open(file).readlines()] | |
| 5114 | |
| 5115 Handle special users (those to ignore in the file, and those who don't | |
| 5116 appear in the file):: | |
| 5117 | |
| 5118 # users to not keep ever, pre-load with the users I know aren't | |
| 5119 # "real" users | |
| 5120 ignore = ['ekmmon', 'bfast', 'csrmail'] | |
| 5121 | |
| 5122 # users to keep - pre-load with the roundup-specific users | |
| 5123 keep = ['comment_pool', 'network_pool', 'admin', 'dev-team', | |
| 5124 'cs_pool', 'anonymous', 'system_pool', 'automated'] | |
| 5125 | |
| 5126 Now we map the UN*X group numbers to the Roles that users should have:: | |
| 5127 | |
| 5128 roles = { | |
| 5129 '501': 'User,Tech', # tech | |
| 5130 '502': 'User', # finance | |
| 5131 '503': 'User,CSR', # customer service reps | |
| 5132 '504': 'User', # sales | |
| 5133 '505': 'User', # marketing | |
| 5134 } | |
| 5135 | |
| 5136 Now we do all the work. Note that the body of the script (where we have | |
| 5137 the tracker database open) is wrapped in a ``try`` / ``finally`` clause, | |
| 5138 so that we always close the database cleanly when we're finished. So, we | |
| 5139 now do all the work:: | |
| 5140 | |
| 5141 # open the database | |
| 5142 db = tracker.open('admin') | |
| 5143 try: | |
| 5144 # store away messages to send to the tracker admins | |
| 5145 msg = [] | |
| 5146 | |
| 5147 # loop over the users list read in from the passwd file | |
| 5148 for user,passw,uid,gid,real,home,shell in users: | |
| 5149 if user in ignore: | |
| 5150 # this user shouldn't appear in our tracker | |
| 5151 continue | |
| 5152 keep.append(user) | |
| 5153 try: | |
| 5154 # see if the user exists in the tracker | |
| 5155 uid = db.user.lookup(user) | |
| 5156 | |
| 5157 # yes, they do - now check the real name for correctness | |
| 5158 if real != db.user.get(uid, 'realname'): | |
| 5159 db.user.set(uid, realname=real) | |
| 5160 msg.append('FIX %s - %s'%(user, real)) | |
| 5161 except KeyError: | |
| 5162 # nope, the user doesn't exist | |
| 5163 db.user.create(username=user, realname=real, | |
| 5164 address='%s@ekit-inc.com'%user, roles=roles[gid]) | |
| 5165 msg.append('ADD %s - %s (%s)'%(user, real, roles[gid])) | |
| 5166 | |
| 5167 # now check that all the users in the tracker are also in our | |
| 5168 # "keep" list - retire those who aren't | |
| 5169 for uid in db.user.list(): | |
| 5170 user = db.user.get(uid, 'username') | |
| 5171 if user not in keep: | |
| 5172 db.user.retire(uid) | |
| 5173 msg.append('RET %s'%user) | |
| 5174 | |
| 5175 # if we did work, then send email to the tracker admins | |
| 5176 if msg: | |
| 5177 # create the email | |
| 5178 msg = '''Subject: %s user database maintenance | |
| 5179 | |
| 5180 %s | |
| 5181 '''%(db.config.TRACKER_NAME, '\n'.join(msg)) | |
| 5182 | |
| 5183 # send the email | |
| 5184 smtp = smtplib.SMTP(db.config.MAILHOST) | |
| 5185 addr = db.config.ADMIN_EMAIL | |
| 5186 smtp.sendmail(addr, addr, msg) | |
| 5187 | |
| 5188 # now we're done - commit the changes | |
| 5189 db.commit() | |
| 5190 finally: | |
| 5191 # always close the database cleanly | |
| 5192 db.close() | |
| 5193 | |
| 5194 And that's it! | |
| 5195 | |
| 5196 | |
| 5197 Using an LDAP database for user information | |
| 5198 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5199 | |
| 5200 A script that reads users from an LDAP store using | |
| 5201 https://pypi.org/project/python-ldap/ and then compares the list to the users in the | |
| 5202 roundup user database would be pretty easy to write. You'd then have it run | |
| 5203 once an hour / day (or on demand if you can work that into your LDAP store | |
| 5204 workflow). See the example `Using a UN*X passwd file as the user database`_ | |
| 5205 for more information about doing this. | |
| 5206 | |
| 5207 To authenticate off the LDAP store (rather than using the passwords in the | |
| 5208 Roundup user database) you'd use the same python-ldap module inside an | |
| 5209 extension to the cgi interface. You'd do this by overriding the method called | |
| 5210 ``verifyPassword`` on the ``LoginAction`` class in your tracker's | |
| 5211 ``extensions`` directory (see `using an external password validation | |
| 5212 source`_). The method is implemented by default as:: | |
| 5213 | |
| 5214 def verifyPassword(self, userid, password): | |
| 5215 ''' Verify the password that the user has supplied | |
| 5216 ''' | |
| 5217 stored = self.db.user.get(self.userid, 'password') | |
| 5218 if password == stored: | |
| 5219 return 1 | |
| 5220 if not password and not stored: | |
| 5221 return 1 | |
| 5222 return 0 | |
| 5223 | |
| 5224 So you could reimplement this as something like:: | |
| 5225 | |
| 5226 def verifyPassword(self, userid, password): | |
| 5227 ''' Verify the password that the user has supplied | |
| 5228 ''' | |
| 5229 # look up some unique LDAP information about the user | |
| 5230 username = self.db.user.get(self.userid, 'username') | |
| 5231 # now verify the password supplied against the LDAP store | |
| 5232 | |
| 5233 | |
| 5234 Changes to Tracker Behaviour | |
| 5235 ---------------------------- | |
| 5236 | |
| 5237 .. index:: single: auditors; how to register (example) | |
| 5238 single: reactors; how to register (example) | |
| 5239 | |
| 5240 Preventing SPAM | |
| 5241 ~~~~~~~~~~~~~~~ | |
| 5242 | |
| 5243 The following detector code may be installed in your tracker's | |
| 5244 ``detectors`` directory. It will block any messages being created that | |
| 5245 have HTML attachments (a very common vector for spam and phishing) | |
| 5246 and any messages that have more than 2 HTTP URLs in them. Just copy | |
| 5247 the following into ``detectors/anti_spam.py`` in your tracker:: | |
| 5248 | |
| 5249 from roundup.exceptions import Reject | |
| 5250 | |
| 5251 def reject_html(db, cl, nodeid, newvalues): | |
| 5252 if newvalues['type'] == 'text/html': | |
| 5253 raise Reject('not allowed') | |
| 5254 | |
| 5255 def reject_manylinks(db, cl, nodeid, newvalues): | |
| 5256 content = newvalues['content'] | |
| 5257 if content.count('http://') > 2: | |
| 5258 raise Reject('not allowed') | |
| 5259 | |
| 5260 def init(db): | |
| 5261 db.file.audit('create', reject_html) | |
| 5262 db.msg.audit('create', reject_manylinks) | |
| 5263 | |
| 5264 You may also wish to block image attachments if your tracker does not | |
| 5265 need that ability:: | |
| 5266 | |
| 5267 if newvalues['type'].startswith('image/'): | |
| 5268 raise Reject('not allowed') | |
| 5269 | |
| 5270 | |
| 5271 Stop "nosy" messages going to people on vacation | |
| 5272 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5273 | |
| 5274 When users go on vacation and set up vacation email bouncing, you'll | |
| 5275 start to see a lot of messages come back through Roundup "Fred is on | |
| 5276 vacation". Not very useful, and relatively easy to stop. | |
| 5277 | |
| 5278 1. add a "vacation" flag to your users:: | |
| 5279 | |
| 5280 user = Class(db, "user", | |
| 5281 username=String(), password=Password(), | |
| 5282 address=String(), realname=String(), | |
| 5283 phone=String(), organisation=String(), | |
| 5284 alternate_addresses=String(), | |
| 5285 roles=String(), queries=Multilink("query"), | |
| 5286 vacation=Boolean()) | |
| 5287 | |
| 5288 2. So that users may edit the vacation flags, add something like the | |
| 5289 following to your ``user.item`` template:: | |
| 5290 | |
| 5291 <tr> | |
| 5292 <th>On Vacation</th> | |
| 5293 <td tal:content="structure context/vacation/field">vacation</td> | |
| 5294 </tr> | |
| 5295 | |
| 5296 3. edit your detector ``nosyreactor.py`` so that the ``nosyreaction()`` | |
| 5297 consists of:: | |
| 5298 | |
| 5299 def nosyreaction(db, cl, nodeid, oldvalues): | |
| 5300 users = db.user | |
| 5301 messages = db.msg | |
| 5302 # send a copy of all new messages to the nosy list | |
| 5303 for msgid in determineNewMessages(cl, nodeid, oldvalues): | |
| 5304 try: | |
| 5305 # figure the recipient ids | |
| 5306 sendto = [] | |
| 5307 seen_message = {} | |
| 5308 recipients = messages.get(msgid, 'recipients') | |
| 5309 for recipid in messages.get(msgid, 'recipients'): | |
| 5310 seen_message[recipid] = 1 | |
| 5311 | |
| 5312 # figure the author's id, and indicate they've received | |
| 5313 # the message | |
| 5314 authid = messages.get(msgid, 'author') | |
| 5315 | |
| 5316 # possibly send the message to the author, as long as | |
| 5317 # they aren't anonymous | |
| 5318 if (db.config.MESSAGES_TO_AUTHOR == 'yes' and | |
| 5319 users.get(authid, 'username') != 'anonymous'): | |
| 5320 sendto.append(authid) | |
| 5321 seen_message[authid] = 1 | |
| 5322 | |
| 5323 # now figure the nosy people who weren't recipients | |
| 5324 nosy = cl.get(nodeid, 'nosy') | |
| 5325 for nosyid in nosy: | |
| 5326 # Don't send nosy mail to the anonymous user (that | |
| 5327 # user shouldn't appear in the nosy list, but just | |
| 5328 # in case they do...) | |
| 5329 if users.get(nosyid, 'username') == 'anonymous': | |
| 5330 continue | |
| 5331 # make sure they haven't seen the message already | |
| 5332 if nosyid not in seen_message: | |
| 5333 # send it to them | |
| 5334 sendto.append(nosyid) | |
| 5335 recipients.append(nosyid) | |
| 5336 | |
| 5337 # generate a change note | |
| 5338 if oldvalues: | |
| 5339 note = cl.generateChangeNote(nodeid, oldvalues) | |
| 5340 else: | |
| 5341 note = cl.generateCreateNote(nodeid) | |
| 5342 | |
| 5343 # we have new recipients | |
| 5344 if sendto: | |
| 5345 # filter out the people on vacation | |
| 5346 sendto = [i for i in sendto | |
| 5347 if not users.get(i, 'vacation', 0)] | |
| 5348 | |
| 5349 # map userids to addresses | |
| 5350 sendto = [users.get(i, 'address') for i in sendto] | |
| 5351 | |
| 5352 # update the message's recipients list | |
| 5353 messages.set(msgid, recipients=recipients) | |
| 5354 | |
| 5355 # send the message | |
| 5356 cl.send_message(nodeid, msgid, note, sendto) | |
| 5357 except roundupdb.MessageSendError as message: | |
| 5358 raise roundupdb.DetectorError(message) | |
| 5359 | |
| 5360 Note that this is the standard nosy reaction code, with the small | |
| 5361 addition of:: | |
| 5362 | |
| 5363 # filter out the people on vacation | |
| 5364 sendto = [i for i in sendto if not users.get(i, 'vacation', 0)] | |
| 5365 | |
| 5366 which filters out the users that have the vacation flag set to true. | |
| 5367 | |
| 5368 Adding in state transition control | |
| 5369 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5370 | |
| 5371 Sometimes tracker admins want to control the states to which users may | |
| 5372 move issues. You can do this by following these steps: | |
| 5373 | |
| 5374 1. make "status" a required variable. This is achieved by adding the | |
| 5375 following to the top of the form in the ``issue.item.html`` | |
| 5376 template:: | |
| 5377 | |
| 5378 <input type="hidden" name="@required" value="status"> | |
| 5379 | |
| 5380 This will force users to select a status. | |
| 5381 | |
| 5382 2. add a Multilink property to the status class:: | |
| 5383 | |
| 5384 stat = Class(db, "status", ... , transitions=Multilink('status'), | |
| 5385 ...) | |
| 5386 | |
| 5387 and then edit the statuses already created, either: | |
| 5388 | |
| 5389 a. through the web using the class list -> status class editor, or | |
| 5390 b. using the ``roundup-admin`` "set" command. | |
| 5391 | |
| 5392 3. add an auditor module ``checktransition.py`` in your tracker's | |
| 5393 ``detectors`` directory, for example:: | |
| 5394 | |
| 5395 def checktransition(db, cl, nodeid, newvalues): | |
| 5396 ''' Check that the desired transition is valid for the "status" | |
| 5397 property. | |
| 5398 ''' | |
| 5399 if 'status' not in newvalues: | |
| 5400 return | |
| 5401 current = cl.get(nodeid, 'status') | |
| 5402 new = newvalues['status'] | |
| 5403 if new == current: | |
| 5404 return | |
| 5405 ok = db.status.get(current, 'transitions') | |
| 5406 if new not in ok: | |
| 5407 raise ValueError('Status not allowed to move from "%s" to "%s"'%( | |
| 5408 db.status.get(current, 'name'), db.status.get(new, 'name'))) | |
| 5409 | |
| 5410 def init(db): | |
| 5411 db.issue.audit('set', checktransition) | |
| 5412 | |
| 5413 4. in the ``issue.item.html`` template, change the status editing bit | |
| 5414 from:: | |
| 5415 | |
| 5416 <th>Status</th> | |
| 5417 <td tal:content="structure context/status/menu">status</td> | |
| 5418 | |
| 5419 to:: | |
| 5420 | |
| 5421 <th>Status</th> | |
| 5422 <td> | |
| 5423 <select tal:condition="context/id" name="status"> | |
| 5424 <tal:block tal:define="ok context/status/transitions" | |
| 5425 tal:repeat="state db/status/list"> | |
| 5426 <option tal:condition="python:state.id in ok" | |
| 5427 tal:attributes=" | |
| 5428 value state/id; | |
| 5429 selected python:state.id == context.status.id" | |
| 5430 tal:content="state/name"></option> | |
| 5431 </tal:block> | |
| 5432 </select> | |
| 5433 <tal:block tal:condition="not:context/id" | |
| 5434 tal:replace="structure context/status/menu" /> | |
| 5435 </td> | |
| 5436 | |
| 5437 which displays only the allowed status to transition to. | |
| 5438 | |
| 5439 | |
| 5440 Blocking issues that depend on other issues | |
| 5441 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5442 | |
| 5443 We needed the ability to mark certain issues as "blockers" - that is, | |
| 5444 they can't be resolved until another issue (the blocker) they rely on is | |
| 5445 resolved. To achieve this: | |
| 5446 | |
| 5447 1. Create a new property on the ``issue`` class: | |
| 5448 ``blockers=Multilink("issue")``. To do this, edit the definition of | |
| 5449 this class in your tracker's ``schema.py`` file. Change this:: | |
| 5450 | |
| 5451 issue = IssueClass(db, "issue", | |
| 5452 assignedto=Link("user"), keyword=Multilink("keyword"), | |
| 5453 priority=Link("priority"), status=Link("status")) | |
| 5454 | |
| 5455 to this, adding the blockers entry:: | |
| 5456 | |
| 5457 issue = IssueClass(db, "issue", | |
| 5458 blockers=Multilink("issue"), | |
| 5459 assignedto=Link("user"), keyword=Multilink("keyword"), | |
| 5460 priority=Link("priority"), status=Link("status")) | |
| 5461 | |
| 5462 2. Add the new ``blockers`` property to the ``issue.item.html`` edit | |
| 5463 page, using something like:: | |
| 5464 | |
| 5465 <th>Waiting On</th> | |
| 5466 <td> | |
| 5467 <span tal:replace="structure python:context.blockers.field(showid=1, | |
| 5468 size=20)" /> | |
| 5469 <span tal:replace="structure python:db.issue.classhelp('id,title', | |
| 5470 property='blockers')" /> | |
| 5471 <span tal:condition="context/blockers" | |
| 5472 tal:repeat="blk context/blockers"> | |
| 5473 <br>View: <a tal:attributes="href string:issue${blk/id}" | |
| 5474 tal:content="blk/id"></a> | |
| 5475 </span> | |
| 5476 </td> | |
| 5477 | |
| 5478 You'll need to fiddle with your item page layout to find an | |
| 5479 appropriate place to put it - I'll leave that fun part up to you. | |
| 5480 Just make sure it appears in the first table, possibly somewhere near | |
| 5481 the "superseders" field. | |
| 5482 | |
| 5483 3. Create a new detector module (see below) which enforces the rules: | |
| 5484 | |
| 5485 - issues may not be resolved if they have blockers | |
| 5486 - when a blocker is resolved, it's removed from issues it blocks | |
| 5487 | |
| 5488 The contents of the detector should be something like this:: | |
| 5489 | |
| 5490 | |
| 5491 def blockresolution(db, cl, nodeid, newvalues): | |
| 5492 ''' If the issue has blockers, don't allow it to be resolved. | |
| 5493 ''' | |
| 5494 if nodeid is None: | |
| 5495 blockers = [] | |
| 5496 else: | |
| 5497 blockers = cl.get(nodeid, 'blockers') | |
| 5498 blockers = newvalues.get('blockers', blockers) | |
| 5499 | |
| 5500 # don't do anything if there's no blockers or the status hasn't | |
| 5501 # changed | |
| 5502 if not blockers or 'status' not in newvalues: | |
| 5503 return | |
| 5504 | |
| 5505 # get the resolved state ID | |
| 5506 resolved_id = db.status.lookup('resolved') | |
| 5507 | |
| 5508 # format the info | |
| 5509 u = db.config.TRACKER_WEB | |
| 5510 s = ', '.join(['<a href="%sissue%s">%s</a>'%( | |
| 5511 u,id,id) for id in blockers]) | |
| 5512 if len(blockers) == 1: | |
| 5513 s = 'issue %s is'%s | |
| 5514 else: | |
| 5515 s = 'issues %s are'%s | |
| 5516 | |
| 5517 # ok, see if we're trying to resolve | |
| 5518 if newvalues['status'] == resolved_id: | |
| 5519 raise ValueError("This issue can't be resolved until %s resolved."%s) | |
| 5520 | |
| 5521 | |
| 5522 def resolveblockers(db, cl, nodeid, oldvalues): | |
| 5523 ''' When we resolve an issue that's a blocker, remove it from the | |
| 5524 blockers list of the issue(s) it blocks. | |
| 5525 ''' | |
| 5526 newstatus = cl.get(nodeid,'status') | |
| 5527 | |
| 5528 # no change? | |
| 5529 if oldvalues.get('status', None) == newstatus: | |
| 5530 return | |
| 5531 | |
| 5532 resolved_id = db.status.lookup('resolved') | |
| 5533 | |
| 5534 # interesting? | |
| 5535 if newstatus != resolved_id: | |
| 5536 return | |
| 5537 | |
| 5538 # yes - find all the blocked issues, if any, and remove me from | |
| 5539 # their blockers list | |
| 5540 issues = cl.find(blockers=nodeid) | |
| 5541 for issueid in issues: | |
| 5542 blockers = cl.get(issueid, 'blockers') | |
| 5543 if nodeid in blockers: | |
| 5544 blockers.remove(nodeid) | |
| 5545 cl.set(issueid, blockers=blockers) | |
| 5546 | |
| 5547 def init(db): | |
| 5548 # might, in an obscure situation, happen in a create | |
| 5549 db.issue.audit('create', blockresolution) | |
| 5550 db.issue.audit('set', blockresolution) | |
| 5551 | |
| 5552 # can only happen on a set | |
| 5553 db.issue.react('set', resolveblockers) | |
| 5554 | |
| 5555 Put the above code in a file called "blockers.py" in your tracker's | |
| 5556 "detectors" directory. | |
| 5557 | |
| 5558 4. Finally, and this is an optional step, modify the tracker web page | |
| 5559 URLs so they filter out issues with any blockers. You do this by | |
| 5560 adding an additional filter on "blockers" for the value "-1". For | |
| 5561 example, the existing "Show All" link in the "page" template (in the | |
| 5562 tracker's "html" directory) looks like this:: | |
| 5563 | |
| 5564 <a href="#" | |
| 5565 tal:attributes="href python:request.indexargs_url('issue', { | |
| 5566 '@sort': '-activity', | |
| 5567 '@group': 'priority', | |
| 5568 '@filter': 'status', | |
| 5569 '@columns': columns_showall, | |
| 5570 '@search_text': '', | |
| 5571 'status': status_notresolved, | |
| 5572 '@dispname': i18n.gettext('Show All'), | |
| 5573 })" | |
| 5574 i18n:translate="">Show All</a><br> | |
| 5575 | |
| 5576 modify it to add the "blockers" info to the URL (note, both the | |
| 5577 "@filter" *and* "blockers" values must be specified):: | |
| 5578 | |
| 5579 <a href="#" | |
| 5580 tal:attributes="href python:request.indexargs_url('issue', { | |
| 5581 '@sort': '-activity', | |
| 5582 '@group': 'priority', | |
| 5583 '@filter': 'status,blockers', | |
| 5584 '@columns': columns_showall, | |
| 5585 '@search_text': '', | |
| 5586 'status': status_notresolved, | |
| 5587 'blockers': '-1', | |
| 5588 '@dispname': i18n.gettext('Show All'), | |
| 5589 })" | |
| 5590 i18n:translate="">Show All</a><br> | |
| 5591 | |
| 5592 The above examples are line-wrapped on the trailing & and should | |
| 5593 be unwrapped. | |
| 5594 | |
| 5595 That's it. You should now be able to set blockers on your issues. Note | |
| 5596 that if you want to know whether an issue has any other issues dependent | |
| 5597 on it (i.e. it's in their blockers list) you can look at the journal | |
| 5598 history at the bottom of the issue page - look for a "link" event to | |
| 5599 another issue's "blockers" property. | |
| 5600 | |
| 5601 Add users to the nosy list based on the keyword | |
| 5602 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5603 | |
| 5604 Let's say we need the ability to automatically add users to the nosy | |
| 5605 list based | |
| 5606 on the occurance of a keyword. Every user should be allowed to edit their | |
| 5607 own list of keywords for which they want to be added to the nosy list. | |
| 5608 | |
| 5609 Below, we'll show that this change can be done with minimal | |
| 5610 understanding of the Roundup system, using only copy and paste. | |
| 5611 | |
| 5612 This requires three changes to the tracker: a change in the database to | |
| 5613 allow per-user recording of the lists of keywords for which he wants to | |
| 5614 be put on the nosy list, a change in the user view allowing them to edit | |
| 5615 this list of keywords, and addition of an auditor which updates the nosy | |
| 5616 list when a keyword is set. | |
| 5617 | |
| 5618 Adding the nosy keyword list | |
| 5619 :::::::::::::::::::::::::::: | |
| 5620 | |
| 5621 The change to make in the database, is that for any user there should be a list | |
| 5622 of keywords for which he wants to be put on the nosy list. Adding a | |
| 5623 ``Multilink`` of ``keyword`` seems to fullfill this. As such, all that has to | |
| 5624 be done is to add a new field to the definition of ``user`` within the file | |
| 5625 ``schema.py``. We will call this new field ``nosy_keywords``, and the updated | |
| 5626 definition of user will be:: | |
| 5627 | |
| 5628 user = Class(db, "user", | |
| 5629 username=String(), password=Password(), | |
| 5630 address=String(), realname=String(), | |
| 5631 phone=String(), organisation=String(), | |
| 5632 alternate_addresses=String(), | |
| 5633 queries=Multilink('query'), roles=String(), | |
| 5634 timezone=String(), | |
| 5635 nosy_keywords=Multilink('keyword')) | |
| 5636 | |
| 5637 Changing the user view to allow changing the nosy keyword list | |
| 5638 :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: | |
| 5639 | |
| 5640 We want any user to be able to change the list of keywords for which | |
| 5641 he will by default be added to the nosy list. We choose to add this | |
| 5642 to the user view, as is generated by the file ``html/user.item.html``. | |
| 5643 We can easily | |
| 5644 see that the keyword field in the issue view has very similar editing | |
| 5645 requirements as our nosy keywords, both being lists of keywords. As | |
| 5646 such, we look for Keywords in ``issue.item.html``, and extract the | |
| 5647 associated parts from there. We add this to ``user.item.html`` at the | |
| 5648 bottom of the list of viewed items (i.e. just below the 'Alternate | |
| 5649 E-mail addresses' in the classic template):: | |
| 5650 | |
| 5651 <tr> | |
| 5652 <th>Nosy Keywords</th> | |
| 5653 <td> | |
| 5654 <span tal:replace="structure context/nosy_keywords/field" /> | |
| 5655 <span tal:replace="structure python:db.keyword.classhelp(property='nosy_keywords')" /> | |
| 5656 </td> | |
| 5657 </tr> | |
| 5658 | |
| 5659 | |
| 5660 Addition of an auditor to update the nosy list | |
| 5661 :::::::::::::::::::::::::::::::::::::::::::::: | |
| 5662 | |
| 5663 The more difficult part is the logic to add | |
| 5664 the users to the nosy list when required. | |
| 5665 We choose to perform this action whenever the keywords on an | |
| 5666 item are set (this includes the creation of items). | |
| 5667 Here we choose to start out with a copy of the | |
| 5668 ``detectors/nosyreaction.py`` detector, which we copy to the file | |
| 5669 ``detectors/nosy_keyword_reaction.py``. | |
| 5670 This looks like a good start as it also adds users | |
| 5671 to the nosy list. A look through the code reveals that the | |
| 5672 ``nosyreaction`` function actually sends the e-mail. | |
| 5673 We don't need this. Therefore, we can change the ``init`` function to:: | |
| 5674 | |
| 5675 def init(db): | |
| 5676 db.issue.audit('create', update_kw_nosy) | |
| 5677 db.issue.audit('set', update_kw_nosy) | |
| 5678 | |
| 5679 After that, we rename the ``updatenosy`` function to ``update_kw_nosy``. | |
| 5680 The first two blocks of code in that function relate to setting | |
| 5681 ``current`` to a combination of the old and new nosy lists. This | |
| 5682 functionality is left in the new auditor. The following block of | |
| 5683 code, which handled adding the assignedto user(s) to the nosy list in | |
| 5684 ``updatenosy``, should be replaced by a block of code to add the | |
| 5685 interested users to the nosy list. We choose here to loop over all | |
| 5686 new keywords, than looping over all users, | |
| 5687 and assign the user to the nosy list when the keyword occurs in the user's | |
| 5688 ``nosy_keywords``. The next part in ``updatenosy`` -- adding the author | |
| 5689 and/or recipients of a message to the nosy list -- is obviously not | |
| 5690 relevant here and is thus deleted from the new auditor. The last | |
| 5691 part, copying the new nosy list to ``newvalues``, can stay as is. | |
| 5692 This results in the following function:: | |
| 5693 | |
| 5694 def update_kw_nosy(db, cl, nodeid, newvalues): | |
| 5695 '''Update the nosy list for changes to the keywords | |
| 5696 ''' | |
| 5697 # nodeid will be None if this is a new node | |
| 5698 current = {} | |
| 5699 if nodeid is None: | |
| 5700 ok = ('new', 'yes') | |
| 5701 else: | |
| 5702 ok = ('yes',) | |
| 5703 # old node, get the current values from the node if they haven't | |
| 5704 # changed | |
| 5705 if 'nosy' not in newvalues: | |
| 5706 nosy = cl.get(nodeid, 'nosy') | |
| 5707 for value in nosy: | |
| 5708 if value not in current: | |
| 5709 current[value] = 1 | |
| 5710 | |
| 5711 # if the nosy list changed in this transaction, init from the new value | |
| 5712 if 'nosy' in newvalues: | |
| 5713 nosy = newvalues.get('nosy', []) | |
| 5714 for value in nosy: | |
| 5715 if not db.hasnode('user', value): | |
| 5716 continue | |
| 5717 if value not in current: | |
| 5718 current[value] = 1 | |
| 5719 | |
| 5720 # add users with keyword in nosy_keywords to the nosy list | |
| 5721 if 'keyword' in newvalues and newvalues['keyword'] is not None: | |
| 5722 keyword_ids = newvalues['keyword'] | |
| 5723 for keyword in keyword_ids: | |
| 5724 # loop over all users, | |
| 5725 # and assign user to nosy when keyword in nosy_keywords | |
| 5726 for user_id in db.user.list(): | |
| 5727 nosy_kw = db.user.get(user_id, "nosy_keywords") | |
| 5728 found = 0 | |
| 5729 for kw in nosy_kw: | |
| 5730 if kw == keyword: | |
| 5731 found = 1 | |
| 5732 if found: | |
| 5733 current[user_id] = 1 | |
| 5734 | |
| 5735 # that's it, save off the new nosy list | |
| 5736 newvalues['nosy'] = list(current.keys()) | |
| 5737 | |
| 5738 These two function are the only ones needed in the file. | |
| 5739 | |
| 5740 TODO: update this example to use the ``find()`` Class method. | |
| 5741 | |
| 5742 Caveats | |
| 5743 ::::::: | |
| 5744 | |
| 5745 A few problems with the design here can be noted: | |
| 5746 | |
| 5747 Multiple additions | |
| 5748 When a user, after automatic selection, is manually removed | |
| 5749 from the nosy list, he is added to the nosy list again when the | |
| 5750 keyword list of the issue is updated. A better design might be | |
| 5751 to only check which keywords are new compared to the old list | |
| 5752 of keywords, and only add users when they have indicated | |
| 5753 interest on a new keyword. | |
| 5754 | |
| 5755 The code could also be changed to only trigger on the ``create()`` | |
| 5756 event, rather than also on the ``set()`` event, thus only setting | |
| 5757 the nosy list when the issue is created. | |
| 5758 | |
| 5759 Scalability | |
| 5760 In the auditor, there is a loop over all users. For a site with | |
| 5761 only few users this will pose no serious problem; however, with | |
| 5762 many users this will be a serious performance bottleneck. | |
| 5763 A way out would be to link from the keywords to the users who | |
| 5764 selected these keywords as nosy keywords. This will eliminate the | |
| 5765 loop over all users. See the ``rev_multilink`` attribute to make | |
| 5766 this easier. | |
| 5767 | |
| 5768 Restricting updates that arrive by email | |
| 5769 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5770 | |
| 5771 Roundup supports multiple update methods: | |
| 5772 | |
| 5773 1. command line | |
| 5774 2. plain email | |
| 5775 3. pgp signed email | |
| 5776 4. web access | |
| 5777 | |
| 5778 in some cases you may need to prevent changes to properties by some of | |
| 5779 these methods. For example you can set up issues that are viewable | |
| 5780 only by people on the nosy list. So you must prevent unauthenticated | |
| 5781 changes to the nosy list. | |
| 5782 | |
| 5783 Since plain email can be easily forged, it does not provide sufficient | |
| 5784 authentication in this senario. | |
| 5785 | |
| 5786 To prevent this we can add a detector that audits the source of the | |
| 5787 transaction and rejects the update if it changes the nosy list. | |
| 5788 | |
| 5789 Create the detector (auditor) module and add it to the detectors | |
| 5790 directory of your tracker:: | |
| 5791 | |
| 5792 from roundup import roundupdb, hyperdb | |
| 5793 | |
| 5794 from roundup.mailgw import Unauthorized | |
| 5795 | |
| 5796 def restrict_nosy_changes(db, cl, nodeid, newvalues): | |
| 5797 '''Do not permit changes to nosy via email.''' | |
| 5798 | |
| 5799 if 'nosy' not in newvalues: | |
| 5800 # the nosy field has not changed so no need to check. | |
| 5801 return | |
| 5802 | |
| 5803 if db.tx_Source in ['web', 'rest', 'xmlrpc', 'email-sig-openpgp', 'cli' ]: | |
| 5804 # if the source of the transaction is from an authenticated | |
| 5805 # source or a privileged process allow the transaction. | |
| 5806 # Other possible sources: 'email' | |
| 5807 return | |
| 5808 | |
| 5809 # otherwise raise an error | |
| 5810 raise Unauthorized( \ | |
| 5811 'Changes to nosy property not allowed via %s for this issue.'%\ | |
| 5812 tx_Source) | |
| 5813 | |
| 5814 def init(db): | |
| 5815 ''' Install restrict_nosy_changes to run after other auditors. | |
| 5816 | |
| 5817 Allow initial creation email to set nosy. | |
| 5818 So don't execute: db.issue.audit('create', requestedbyauditor) | |
| 5819 | |
| 5820 Set priority to 110 to run this auditor after other auditors | |
| 5821 that can cause nosy to change. | |
| 5822 ''' | |
| 5823 db.issue.audit('set', restrict_nosy_changes, 110) | |
| 5824 | |
| 5825 This detector (auditor) will prevent updates to the nosy field if it | |
| 5826 arrives by email. Since it runs after other auditors (due to the | |
| 5827 priority of 110), it will also prevent changes to the nosy field that | |
| 5828 are done by other auditors if triggered by an email. | |
| 5829 | |
| 5830 Note that db.tx_Source was not present in roundup versions before | |
| 5831 1.4.22, so you must be running a newer version to use this detector. | |
| 5832 Read the CHANGES.txt document in the roundup source code for further | |
| 5833 details on tx_Source. | |
| 5834 | |
| 5835 Changes to Security and Permissions | |
| 5836 ----------------------------------- | |
| 5837 | |
| 5838 Restricting the list of users that are assignable to a task | |
| 5839 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5840 | |
| 5841 1. In your tracker's ``schema.py``, create a new Role, say "Developer":: | |
| 5842 | |
| 5843 db.security.addRole(name='Developer', description='A developer') | |
| 5844 | |
| 5845 2. Just after that, create a new Permission, say "Fixer", specific to | |
| 5846 "issue":: | |
| 5847 | |
| 5848 p = db.security.addPermission(name='Fixer', klass='issue', | |
| 5849 description='User is allowed to be assigned to fix issues') | |
| 5850 | |
| 5851 3. Then assign the new Permission to your "Developer" Role:: | |
| 5852 | |
| 5853 db.security.addPermissionToRole('Developer', p) | |
| 5854 | |
| 5855 4. In the issue item edit page (``html/issue.item.html`` in your tracker | |
| 5856 directory), use the new Permission in restricting the "assignedto" | |
| 5857 list:: | |
| 5858 | |
| 5859 <select name="assignedto"> | |
| 5860 <option value="-1">- no selection -</option> | |
| 5861 <tal:block tal:repeat="user db/user/list"> | |
| 5862 <option tal:condition="python:user.hasPermission( | |
| 5863 'Fixer', context._classname)" | |
| 5864 tal:attributes=" | |
| 5865 value user/id; | |
| 5866 selected python:user.id == context.assignedto" | |
| 5867 tal:content="user/realname"></option> | |
| 5868 </tal:block> | |
| 5869 </select> | |
| 5870 | |
| 5871 For extra security, you may wish to setup an auditor to enforce the | |
| 5872 Permission requirement (install this as ``assignedtoFixer.py`` in your | |
| 5873 tracker ``detectors`` directory):: | |
| 5874 | |
| 5875 def assignedtoMustBeFixer(db, cl, nodeid, newvalues): | |
| 5876 ''' Ensure the assignedto value in newvalues is used with the | |
| 5877 Fixer Permission | |
| 5878 ''' | |
| 5879 if 'assignedto' not in newvalues: | |
| 5880 # don't care | |
| 5881 return | |
| 5882 | |
| 5883 # get the userid | |
| 5884 userid = newvalues['assignedto'] | |
| 5885 if not db.security.hasPermission('Fixer', userid, cl.classname): | |
| 5886 raise ValueError('You do not have permission to edit %s'%cl.classname) | |
| 5887 | |
| 5888 def init(db): | |
| 5889 db.issue.audit('set', assignedtoMustBeFixer) | |
| 5890 db.issue.audit('create', assignedtoMustBeFixer) | |
| 5891 | |
| 5892 So now, if an edit action attempts to set "assignedto" to a user that | |
| 5893 doesn't have the "Fixer" Permission, the error will be raised. | |
| 5894 | |
| 5895 | |
| 5896 Users may only edit their issues | |
| 5897 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5898 | |
| 5899 In this case, users registering themselves are granted Provisional | |
| 5900 access, meaning they | |
| 5901 have access to edit the issues they submit, but not others. We create a new | |
| 5902 Role called "Provisional User" which is granted to newly-registered users, | |
| 5903 and has limited access. One of the Permissions they have is the new "Edit | |
| 5904 Own" on issues (regular users have "Edit".) | |
| 5905 | |
| 5906 First up, we create the new Role and Permission structure in | |
| 5907 ``schema.py``:: | |
| 5908 | |
| 5909 # | |
| 5910 # New users not approved by the admin | |
| 5911 # | |
| 5912 db.security.addRole(name='Provisional User', | |
| 5913 description='New user registered via web or email') | |
| 5914 | |
| 5915 # These users need to be able to view and create issues but only edit | |
| 5916 # and view their own | |
| 5917 db.security.addPermissionToRole('Provisional User', 'Create', 'issue') | |
| 5918 def own_issue(db, userid, itemid): | |
| 5919 '''Determine whether the userid matches the creator of the issue.''' | |
| 5920 return userid == db.issue.get(itemid, 'creator') | |
| 5921 p = db.security.addPermission(name='Edit', klass='issue', | |
| 5922 check=own_issue, description='Can only edit own issues') | |
| 5923 db.security.addPermissionToRole('Provisional User', p) | |
| 5924 p = db.security.addPermission(name='View', klass='issue', | |
| 5925 check=own_issue, description='Can only view own issues') | |
| 5926 db.security.addPermissionToRole('Provisional User', p) | |
| 5927 # This allows the interface to get the names of the properties | |
| 5928 # in the issue. Used for selecting sorting and grouping | |
| 5929 # on the index page. | |
| 5930 p = db.security.addPermission(name='Search', klass='issue') | |
| 5931 db.security.addPermissionToRole ('Provisional User', p) | |
| 5932 | |
| 5933 | |
| 5934 # Assign the Permissions for issue-related classes | |
| 5935 for cl in 'file', 'msg', 'query', 'keyword': | |
| 5936 db.security.addPermissionToRole('Provisional User', 'View', cl) | |
| 5937 db.security.addPermissionToRole('Provisional User', 'Edit', cl) | |
| 5938 db.security.addPermissionToRole('Provisional User', 'Create', cl) | |
| 5939 for cl in 'priority', 'status': | |
| 5940 db.security.addPermissionToRole('Provisional User', 'View', cl) | |
| 5941 | |
| 5942 # and give the new users access to the web and email interface | |
| 5943 db.security.addPermissionToRole('Provisional User', 'Web Access') | |
| 5944 db.security.addPermissionToRole('Provisional User', 'Email Access') | |
| 5945 | |
| 5946 # make sure they can view & edit their own user record | |
| 5947 def own_record(db, userid, itemid): | |
| 5948 '''Determine whether the userid matches the item being accessed.''' | |
| 5949 return userid == itemid | |
| 5950 p = db.security.addPermission(name='View', klass='user', check=own_record, | |
| 5951 description="User is allowed to view their own user details") | |
| 5952 db.security.addPermissionToRole('Provisional User', p) | |
| 5953 p = db.security.addPermission(name='Edit', klass='user', check=own_record, | |
| 5954 description="User is allowed to edit their own user details") | |
| 5955 db.security.addPermissionToRole('Provisional User', p) | |
| 5956 | |
| 5957 Then, in ``config.ini``, we change the Role assigned to newly-registered | |
| 5958 users, replacing the existing ``'User'`` values:: | |
| 5959 | |
| 5960 [main] | |
| 5961 ... | |
| 5962 new_web_user_roles = Provisional User | |
| 5963 new_email_user_roles = Provisional User | |
| 5964 | |
| 5965 | |
| 5966 All users may only view and edit issues, files and messages they create | |
| 5967 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5968 | |
| 5969 Replace the standard "classic" tracker View and Edit Permission assignments | |
| 5970 for the "issue", "file" and "msg" classes with the following:: | |
| 5971 | |
| 5972 def checker(klass): | |
| 5973 def check(db, userid, itemid, klass=klass): | |
| 5974 return db.getclass(klass).get(itemid, 'creator') == userid | |
| 5975 return check | |
| 5976 for cl in 'issue', 'file', 'msg': | |
| 5977 p = db.security.addPermission(name='View', klass=cl, | |
| 5978 check=checker(cl), | |
| 5979 description='User can view only if creator.') | |
| 5980 db.security.addPermissionToRole('User', p) | |
| 5981 p = db.security.addPermission(name='Edit', klass=cl, | |
| 5982 check=checker(cl), | |
| 5983 description='User can edit only if creator.') | |
| 5984 db.security.addPermissionToRole('User', p) | |
| 5985 db.security.addPermissionToRole('User', 'Create', cl) | |
| 5986 # This allows the interface to get the names of the properties | |
| 5987 # in the issue. Used for selecting sorting and grouping | |
| 5988 # on the index page. | |
| 5989 p = db.security.addPermission(name='Search', klass='issue') | |
| 5990 db.security.addPermissionToRole ('User', p) | |
| 5991 | |
| 5992 | |
| 5993 Moderating user registration | |
| 5994 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 5995 | |
| 5996 You could set up new-user moderation in a public tracker by: | |
| 5997 | |
| 5998 1. creating a new highly-restricted user role "Pending", | |
| 5999 2. set the config new_web_user_roles and/or new_email_user_roles to that | |
| 6000 role, | |
| 6001 3. have an auditor that emails you when new users are created with that | |
| 6002 role using roundup.mailer | |
| 6003 4. edit the role to "User" for valid users. | |
| 6004 | |
| 6005 Some simple javascript might help in the last step. If you have high volume | |
| 6006 you could search for all currently-Pending users and do a bulk edit of all | |
| 6007 their roles at once (again probably with some simple javascript help). | |
| 6008 | |
| 6009 | |
| 6010 Changes to the Web User Interface | |
| 6011 --------------------------------- | |
| 6012 | |
| 6013 Adding action links to the index page | |
| 6014 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 6015 | |
| 6016 Add a column to the ``item.index.html`` template. | |
| 6017 | |
| 6018 Resolving the issue:: | |
| 6019 | |
| 6020 <a tal:attributes="href | |
| 6021 string:issue${i/id}?:status=resolved&:action=edit">resolve</a> | |
| 6022 | |
| 6023 "Take" the issue:: | |
| 6024 | |
| 6025 <a tal:attributes="href | |
| 6026 string:issue${i/id}?:assignedto=${request/user/id}&:action=edit">take</a> | |
| 6027 | |
| 6028 ... and so on. | |
| 6029 | |
| 6030 Colouring the rows in the issue index according to priority | |
| 6031 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 6032 | |
| 6033 A simple ``tal:attributes`` statement will do the bulk of the work here. In | |
| 6034 the ``issue.index.html`` template, add this to the ``<tr>`` that | |
| 6035 displays the rows of data:: | |
| 6036 | |
| 6037 <tr tal:attributes="class string:priority-${i/priority/plain}"> | |
| 6038 | |
| 6039 and then in your stylesheet (``style.css``) specify the colouring for the | |
| 6040 different priorities, as follows:: | |
| 6041 | |
| 6042 tr.priority-critical td { | |
| 6043 background-color: red; | |
| 6044 } | |
| 6045 | |
| 6046 tr.priority-urgent td { | |
| 6047 background-color: orange; | |
| 6048 } | |
| 6049 | |
| 6050 and so on, with far less offensive colours :) | |
| 6051 | |
| 6052 Editing multiple items in an index view | |
| 6053 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 6054 | |
| 6055 To edit the status of all items in the item index view, edit the | |
| 6056 ``issue.index.html``: | |
| 6057 | |
| 6058 1. add a form around the listing table (separate from the existing | |
| 6059 index-page form), so at the top it reads:: | |
| 6060 | |
| 6061 <form method="POST" tal:attributes="action request/classname"> | |
| 6062 <table class="list"> | |
| 6063 | |
| 6064 and at the bottom of that table:: | |
| 6065 | |
| 6066 </table> | |
| 6067 </form | |
| 6068 | |
| 6069 making sure you match the ``</table>`` from the list table, not the | |
| 6070 navigation table or the subsequent form table. | |
| 6071 | |
| 6072 2. in the display for the issue property, change:: | |
| 6073 | |
| 6074 <td tal:condition="request/show/status" | |
| 6075 tal:content="python:i.status.plain() or default"> </td> | |
| 6076 | |
| 6077 to:: | |
| 6078 | |
| 6079 <td tal:condition="request/show/status" | |
| 6080 tal:content="structure i/status/field"> </td> | |
| 6081 | |
| 6082 this will result in an edit field for the status property. | |
| 6083 | |
| 6084 3. after the ``tal:block`` which lists the index items (marked by | |
| 6085 ``tal:repeat="i batch"``) add a new table row:: | |
| 6086 | |
| 6087 <tr> | |
| 6088 <td tal:attributes="colspan python:len(request.columns)"> | |
| 6089 <input name="@csrf" type="hidden" | |
| 6090 tal:attributes="value python:utils.anti_csrf_nonce()"> | |
| 6091 <input type="submit" value=" Save Changes "> | |
| 6092 <input type="hidden" name="@action" value="edit"> | |
| 6093 <tal:block replace="structure request/indexargs_form" /> | |
| 6094 </td> | |
| 6095 </tr> | |
| 6096 | |
| 6097 which gives us a submit button, indicates that we are performing an | |
| 6098 edit on any changed statuses, and provides a defense against cross | |
| 6099 site request forgery attacks. | |
| 6100 | |
| 6101 The final ``tal:block`` will make sure that the current index view | |
| 6102 parameters (filtering, columns, etc) will be used in rendering the | |
| 6103 next page (the results of the editing). | |
| 6104 | |
| 6105 | |
| 6106 Displaying only message summaries in the issue display | |
| 6107 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 6108 | |
| 6109 Alter the ``issue.item`` template section for messages to:: | |
| 6110 | |
| 6111 <table class="messages" tal:condition="context/messages"> | |
| 6112 <tr><th colspan="5" class="header">Messages</th></tr> | |
| 6113 <tr tal:repeat="msg context/messages"> | |
| 6114 <td><a tal:attributes="href string:msg${msg/id}" | |
| 6115 tal:content="string:msg${msg/id}"></a></td> | |
| 6116 <td tal:content="msg/author">author</td> | |
| 6117 <td class="date" tal:content="msg/date/pretty">date</td> | |
| 6118 <td tal:content="msg/summary">summary</td> | |
| 6119 <td> | |
| 6120 <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit"> | |
| 6121 remove</a> | |
| 6122 </td> | |
| 6123 </tr> | |
| 6124 </table> | |
| 6125 | |
| 6126 | |
| 6127 Enabling display of either message summaries or the entire messages | |
| 6128 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 6129 | |
| 6130 This is pretty simple - all we need to do is copy the code from the | |
| 6131 example `displaying only message summaries in the issue display`_ into | |
| 6132 our template alongside the summary display, and then introduce a switch | |
| 6133 that shows either the one or the other. We'll use a new form variable, | |
| 6134 ``@whole_messages`` to achieve this:: | |
| 6135 | |
| 6136 <table class="messages" tal:condition="context/messages"> | |
| 6137 <tal:block tal:condition="not:request/form/@whole_messages/value | python:0"> | |
| 6138 <tr><th colspan="3" class="header">Messages</th> | |
| 6139 <th colspan="2" class="header"> | |
| 6140 <a href="?@whole_messages=yes">show entire messages</a> | |
| 6141 </th> | |
| 6142 </tr> | |
| 6143 <tr tal:repeat="msg context/messages"> | |
| 6144 <td><a tal:attributes="href string:msg${msg/id}" | |
| 6145 tal:content="string:msg${msg/id}"></a></td> | |
| 6146 <td tal:content="msg/author">author</td> | |
| 6147 <td class="date" tal:content="msg/date/pretty">date</td> | |
| 6148 <td tal:content="msg/summary">summary</td> | |
| 6149 <td> | |
| 6150 <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a> | |
| 6151 </td> | |
| 6152 </tr> | |
| 6153 </tal:block> | |
| 6154 | |
| 6155 <tal:block tal:condition="request/form/@whole_messages/value | python:0"> | |
| 6156 <tr><th colspan="2" class="header">Messages</th> | |
| 6157 <th class="header"> | |
| 6158 <a href="?@whole_messages=">show only summaries</a> | |
| 6159 </th> | |
| 6160 </tr> | |
| 6161 <tal:block tal:repeat="msg context/messages"> | |
| 6162 <tr> | |
| 6163 <th tal:content="msg/author">author</th> | |
| 6164 <th class="date" tal:content="msg/date/pretty">date</th> | |
| 6165 <th style="text-align: right"> | |
| 6166 (<a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a>) | |
| 6167 </th> | |
| 6168 </tr> | |
| 6169 <tr><td colspan="3" tal:content="msg/content"></td></tr> | |
| 6170 </tal:block> | |
| 6171 </tal:block> | |
| 6172 </table> | |
| 6173 | |
| 6174 | |
| 6175 Setting up a "wizard" (or "druid") for controlled adding of issues | |
| 6176 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
| 6177 | |
| 6178 1. Set up the page templates you wish to use for data input. My wizard | |
| 6179 is going to be a two-step process: first figuring out what category | |
| 6180 of issue the user is submitting, and then getting details specific to | |
| 6181 that category. The first page includes a table of help, explaining | |
| 6182 what the category names mean, and then the core of the form:: | |
| 6183 | |
| 6184 <form method="POST" onSubmit="return submit_once()" | |
| 6185 enctype="multipart/form-data"> | |
| 6186 <input name="@csrf" type="hidden" | |
| 6187 tal:attributes="value python:utils.anti_csrf_nonce()"> | |
| 6188 <input type="hidden" name="@template" value="add_page1"> | |
| 6189 <input type="hidden" name="@action" value="page1_submit"> | |
| 6190 | |
| 6191 <strong>Category:</strong> | |
| 6192 <tal:block tal:replace="structure context/category/menu" /> | |
| 6193 <input type="submit" value="Continue"> | |
| 6194 </form> | |
| 6195 | |
| 6196 The next page has the usual issue entry information, with the | |
| 6197 addition of the following form fragments:: | |
| 6198 | |
| 6199 <form method="POST" onSubmit="return submit_once()" | |
| 6200 enctype="multipart/form-data" | |
| 6201 tal:condition="context/is_edit_ok" | |
| 6202 tal:define="cat request/form/category/value"> | |
| 6203 | |
| 6204 <input name="@csrf" type="hidden" | |
| 6205 tal:attributes="value python:utils.anti_csrf_nonce()"> | |
| 6206 <input type="hidden" name="@template" value="add_page2"> | |
| 6207 <input type="hidden" name="@required" value="title"> | |
| 6208 <input type="hidden" name="category" tal:attributes="value cat"> | |
| 6209 . | |
| 6210 . | |
| 6211 . | |
| 6212 </form> | |
| 6213 | |
| 6214 Note that later in the form, I use the value of "cat" to decide which | |
| 6215 form elements should be displayed. For example:: | |
| 6216 | |
| 6217 <tal:block tal:condition="python:cat in '6 10 13 14 15 16 17'.split()"> | |
| 6218 <tr> | |
| 6219 <th>Operating System</th> | |
| 6220 <td tal:content="structure context/os/field"></td> | |
| 6221 </tr> | |
| 6222 <tr> | |
| 6223 <th>Web Browser</th> | |
| 6224 <td tal:content="structure context/browser/field"></td> | |
| 6225 </tr> | |
| 6226 </tal:block> | |
| 6227 | |
| 6228 ... the above section will only be displayed if the category is one | |
| 6229 of 6, 10, 13, 14, 15, 16 or 17. | |
| 6230 | |
| 6231 3. Determine what actions need to be taken between the pages - these are | |
| 6232 usually to validate user choices and determine what page is next. Now encode | |
| 6233 those actions in a new ``Action`` class (see `defining new web actions`_):: | |
| 6234 | |
| 6235 from roundup.cgi.actions import Action | |
| 6236 | |
| 6237 class Page1SubmitAction(Action): | |
| 6238 def handle(self): | |
| 6239 ''' Verify that the user has selected a category, and then move | |
| 6240 on to page 2. | |
| 6241 ''' | |
| 6242 category = self.form['category'].value | |
| 6243 if category == '-1': | |
| 6244 self.client.add_error_message('You must select a category of report') | |
| 6245 return | |
| 6246 # everything's ok, move on to the next page | |
| 6247 self.client.template = 'add_page2' | |
| 6248 | |
| 6249 def init(instance): | |
| 6250 instance.registerAction('page1_submit', Page1SubmitAction) | |
| 6251 | |
| 6252 4. Use the usual "new" action as the ``@action`` on the final page, and | |
| 6253 you're done (the standard context/submit method can do this for you). | |
| 6254 | |
| 6255 | |
| 6256 Silent Submit | |
| 6257 ~~~~~~~~~~~~~ | |
| 6258 | |
| 6259 When working on an issue, most of the time the people on the nosy list | |
| 6260 need to be notified of changes. There are cases where a user wants to | |
| 6261 add a comment to an issue and not bother other users on the nosy | |
| 6262 list. | |
| 6263 This feature is called Silent Submit because it allows the user to | |
| 6264 silently modify an issue and not tell anyone. | |
| 6265 | |
| 6266 There are several parts to this change. The main activity part | |
| 6267 involves editing the stock detectors/nosyreaction.py file in your | |
| 6268 tracker. Insert the following lines near the top of the nosyreaction | |
| 6269 function:: | |
| 6270 | |
| 6271 # Did user click button to do a silent change? | |
| 6272 try: | |
| 6273 if db.web['submit'] == "silent_change": | |
| 6274 return | |
| 6275 except (AttributeError, KeyError) as err: | |
| 6276 # The web attribute or submit key don't exist. | |
| 6277 # That's fine. We were probably triggered by an email | |
| 6278 # or cli based change. | |
| 6279 pass | |
| 6280 | |
| 6281 This checks the submit button to see if it is the silent type. If there | |
| 6282 are exceptions trying to make that determination they are ignored and | |
| 6283 processing continues. You may wonder how db.web gets set. This is done | |
| 6284 by creating an extension. Add the file extensions/edit.py with | |
| 6285 this content:: | |
| 6286 | |
| 6287 from roundup.cgi.actions import EditItemAction | |
| 6288 | |
| 6289 class Edit2Action(EditItemAction): | |
| 6290 def handle(self): | |
| 6291 self.db.web = {} # create the dict | |
| 6292 # populate the dict by getting the value of the submit_button | |
| 6293 # element from the form. | |
| 6294 self.db.web['submit'] = self.form['submit_button'].value | |
| 6295 | |
| 6296 # call the core EditItemAction to process the edit. | |
| 6297 EditItemAction.handle(self) | |
| 6298 | |
| 6299 def init(instance): | |
| 6300 '''Override the default edit action with this new version''' | |
| 6301 instance.registerAction('edit', Edit2Action) | |
| 6302 | |
| 6303 This code is a wrapper for the roundup EditItemAction. It checks the | |
| 6304 form's submit button to save the value element. The rest of the changes | |
| 6305 needed for the Silent Submit feature involves editing | |
| 6306 html/issue.item.html to add the silent submit button. In | |
| 6307 the stock issue.item.html the submit button is on a line that contains | |
| 6308 "submit button". Replace that line with something like the following:: | |
| 6309 | |
| 6310 <input type="submit" name="submit_button" | |
| 6311 tal:condition="context/is_edit_ok" | |
| 6312 value="Submit Changes"> | |
| 6313 <button type="submit" name="submit_button" | |
| 6314 tal:condition="context/is_edit_ok" | |
| 6315 title="Click this to submit but not send nosy email." | |
| 6316 value="silent_change" i18n:translate=""> | |
| 6317 Silent Change</button> | |
| 6318 | |
| 6319 Note the difference in the value attribute for the two submit buttons. | |
| 6320 The value "silent_change" in the button specification must match the | |
| 6321 string in the nosy reaction function. | |
| 6322 | |
| 6323 Debugging Trackers | |
| 6324 ================== | |
| 6325 | |
| 6326 There are three switches in tracker configs that turn on debugging in | |
| 6327 Roundup: | |
| 6328 | |
| 6329 1. web :: debug | |
| 6330 2. mail :: debug | |
| 6331 3. logging :: level | |
| 6332 | |
| 6333 See the config.ini file or the `tracker configuration`_ section above for | |
| 6334 more information. | |
| 6335 | |
| 6336 Additionally, the ``roundup-server.py`` script has its own debugging mode | |
| 6337 in which it reloads edited templates immediately when they are changed, | |
| 6338 rather than requiring a web server restart. | |
| 6339 | |
| 6340 | 2448 |
| 6341 .. _`design documentation`: design.html | 2449 .. _`design documentation`: design.html |
| 6342 .. _`developer's guide`: developers.html | 2450 .. _`developer's guide`: developers.html |
| 6343 .. _`rest interface documentation`: rest.html#programming-the-rest-api | 2451 .. _`rest interface documentation`: rest.html#programming-the-rest-api |
| 2452 .. _change the rate limiting method: rest.html#creating-custom-rate-limits | |
| 6344 .. _`directions in the rest interface documentation`: rest.html#enabling-the-rest-api | 2453 .. _`directions in the rest interface documentation`: rest.html#enabling-the-rest-api |
| 6345 .. _`xmlrpc interface documentation`: xmlrpc.html#through-roundup | 2454 .. _`xmlrpc interface documentation`: xmlrpc.html#through-roundup |
| 6346 .. _`zxcvbn`: https://github.com/dwolfhub/zxcvbn-python | 2455 .. _`zxcvbn`: https://github.com/dwolfhub/zxcvbn-python |
| 6347 | 2456 |
