| OLD | NEW |
| (Empty) |
| 1 # -*- test-case-name: twisted.web.test.test_woven -*- | |
| 2 # | |
| 3 # WORK IN PROGRESS: HARD HAT REQUIRED | |
| 4 # | |
| 5 | |
| 6 from __future__ import nested_scopes | |
| 7 | |
| 8 # Twisted Imports | |
| 9 | |
| 10 from twisted.python import formmethod, failure | |
| 11 from twisted.python.components import registerAdapter | |
| 12 from twisted.web import domhelpers, resource, util | |
| 13 from twisted.internet import defer | |
| 14 | |
| 15 # Sibling Imports | |
| 16 from twisted.web.woven import model, view, controller, widgets, input, interface
s | |
| 17 | |
| 18 from twisted.web.microdom import parseString, lmx, Element | |
| 19 | |
| 20 | |
| 21 #other imports | |
| 22 import math | |
| 23 | |
| 24 # map formmethod.Argument to functions that render them: | |
| 25 _renderers = {} | |
| 26 | |
| 27 def registerRenderer(argumentClass, renderer): | |
| 28 """Register a renderer for a given argument class. | |
| 29 | |
| 30 The renderer function should act in the same way | |
| 31 as the 'input_XXX' methods of C{FormFillerWidget}. | |
| 32 """ | |
| 33 assert callable(renderer) | |
| 34 global _renderers | |
| 35 _renderers[argumentClass] = renderer | |
| 36 | |
| 37 | |
| 38 class FormFillerWidget(widgets.Widget): | |
| 39 | |
| 40 SPANNING_TYPES = ["hidden", "submit"] | |
| 41 | |
| 42 def getValue(self, request, argument): | |
| 43 """Return value for form input.""" | |
| 44 if not self.model.alwaysDefault: | |
| 45 values = request.args.get(argument.name, None) | |
| 46 if values: | |
| 47 try: | |
| 48 return argument.coerce(values[0]) | |
| 49 except formmethod.InputError: | |
| 50 return values[0] | |
| 51 return argument.default | |
| 52 | |
| 53 def getValues(self, request, argument): | |
| 54 """Return values for form input.""" | |
| 55 if not self.model.alwaysDefault: | |
| 56 values = request.args.get(argument.name, None) | |
| 57 if values: | |
| 58 try: | |
| 59 return argument.coerce(values) | |
| 60 except formmethod.InputError: | |
| 61 return values | |
| 62 return argument.default | |
| 63 | |
| 64 def createShell(self, request, node, data): | |
| 65 """Create a `shell' node that will hold the additional form | |
| 66 elements, if one is required. | |
| 67 """ | |
| 68 return lmx(node).table(border="0") | |
| 69 | |
| 70 def input_single(self, request, content, model, templateAttributes={}): | |
| 71 """ | |
| 72 Returns a text input node built based upon the node model. | |
| 73 Optionally takes an already-coded DOM node merges that | |
| 74 information with the model's information. Returns a new (??) | |
| 75 lmx node. | |
| 76 """ | |
| 77 #in a text field, only the following options are allowed (well, more | |
| 78 #are, but they're not supported yet - can add them in later) | |
| 79 attribs = ['type', 'name', 'value', 'size', 'maxlength', | |
| 80 'readonly'] #only MSIE recognizes readonly and disabled | |
| 81 | |
| 82 arguments = {} | |
| 83 for attrib in attribs: | |
| 84 #model hints and values override anything in the template | |
| 85 val = model.getHint(attrib, templateAttributes.get(attrib, None)) | |
| 86 if val: | |
| 87 arguments[attrib] = str(val) | |
| 88 | |
| 89 value = self.getValue(request, model) | |
| 90 if value: | |
| 91 arguments["value"] = str(value) | |
| 92 | |
| 93 arguments["type"] = "text" #these are default | |
| 94 arguments["name"] = model.name | |
| 95 | |
| 96 return content.input(**arguments) | |
| 97 | |
| 98 def input_string(self, request, content, model, templateAttributes={}): | |
| 99 if not templateAttributes.has_key("size"): | |
| 100 templateAttributes["size"] = '60' | |
| 101 return self.input_single(request, content, model, templateAttributes) | |
| 102 | |
| 103 input_integer = input_single | |
| 104 input_integerrange = input_single | |
| 105 input_float = input_single | |
| 106 | |
| 107 def input_text(self, request, content, model, templateAttributes={}): | |
| 108 r = content.textarea( | |
| 109 cols=str(model.getHint('cols', | |
| 110 templateAttributes.get('cols', '60'))), | |
| 111 rows=str(model.getHint('rows', | |
| 112 templateAttributes.get('rows', '10'))), | |
| 113 name=model.name, | |
| 114 wrap=str(model.getHint('wrap', | |
| 115 templateAttributes.get('wrap', "virtual")))) | |
| 116 r.text(str(self.getValue(request, model))) | |
| 117 return r | |
| 118 | |
| 119 def input_hidden(self, request, content, model, templateAttributes={}): | |
| 120 return content.input(type="hidden", | |
| 121 name=model.name, | |
| 122 value=str(self.getValue(request, model))) | |
| 123 | |
| 124 def input_submit(self, request, content, model, templateAttributes={}): | |
| 125 arguments = {} | |
| 126 val = model.getHint("onClick", templateAttributes.get("onClick", None)) | |
| 127 if val: | |
| 128 arguments["onClick"] = val | |
| 129 arguments["type"] = "submit" | |
| 130 arguments["name"] = model.name | |
| 131 div = content.div() | |
| 132 for tag, value, desc in model.choices: | |
| 133 args = arguments.copy() | |
| 134 args["value"] = tag | |
| 135 div.input(**args) | |
| 136 div.text(" ") | |
| 137 if model.reset: | |
| 138 div.input(type="reset") | |
| 139 return div | |
| 140 | |
| 141 def input_choice(self, request, content, model, templateAttributes={}): | |
| 142 # am I not evil? allow onChange js events | |
| 143 arguments = {} | |
| 144 val = model.getHint("onChange", templateAttributes.get("onChange", None)
) | |
| 145 if val: | |
| 146 arguments["onChange"] = val | |
| 147 arguments["name"] = model.name | |
| 148 s = content.select(**arguments) | |
| 149 default = self.getValues(request, model) | |
| 150 for tag, value, desc in model.choices: | |
| 151 kw = {} | |
| 152 if value in default: | |
| 153 kw = {'selected' : '1'} | |
| 154 s.option(value=tag, **kw).text(desc) | |
| 155 return s | |
| 156 | |
| 157 def input_group(self, request, content, model, groupValues, inputType, | |
| 158 templateAttributes={}): | |
| 159 """ | |
| 160 Base code for a group of objects. Checkgroup will use this, as | |
| 161 well as radiogroup. In the attributes, rows means how many rows | |
| 162 the group should be arranged into, cols means how many cols the | |
| 163 group should be arranged into. Columns take precedence over | |
| 164 rows: if both are specified, the output will always generate the | |
| 165 correct number of columns. However, if the number of elements | |
| 166 in the group exceed (or is smaller than) rows*cols, then the | |
| 167 number of rows will be off. A cols attribute of 1 will mean that | |
| 168 all the elements will be listed one underneath another. The | |
| 169 default is a rows attribute of 1: everything listed next to each | |
| 170 other. | |
| 171 """ | |
| 172 rows = model.getHint('rows', templateAttributes.get('rows', None)) | |
| 173 cols = model.getHint('cols', templateAttributes.get('cols', None)) | |
| 174 if rows: | |
| 175 rows = int(rows) | |
| 176 if cols: | |
| 177 cols = int(cols) | |
| 178 | |
| 179 defaults = self.getValues(request, model) | |
| 180 if (rows and rows>1) or (cols and cols>1): #build a table | |
| 181 s = content.table(border="0") | |
| 182 if cols: | |
| 183 breakat = cols | |
| 184 else: | |
| 185 breakat = math.ceil(float(len(groupValues))/rows) | |
| 186 for i in range(0, len(groupValues), breakat): | |
| 187 tr = s.tr() | |
| 188 for j in range(0, breakat): | |
| 189 if i+j >= len(groupValues): | |
| 190 break | |
| 191 tag, value, desc = groupValues[i+j] | |
| 192 kw = {} | |
| 193 if value in defaults: | |
| 194 kw = {'checked' : '1'} | |
| 195 tr.td().input(type=inputType, name=model.name, | |
| 196 value=tag, **kw).text(desc) | |
| 197 | |
| 198 else: | |
| 199 s = content.div() | |
| 200 for tag, value, desc in groupValues: | |
| 201 kw = {} | |
| 202 if value in defaults: | |
| 203 kw = {'checked' : '1'} | |
| 204 s.input(type=inputType, name=model.name, | |
| 205 value=tag, **kw).text(desc) | |
| 206 if cols: | |
| 207 s.br() | |
| 208 | |
| 209 return s | |
| 210 | |
| 211 def input_checkgroup(self, request, content, model, templateAttributes={}): | |
| 212 return self.input_group(request, content, model, model.flags, | |
| 213 "checkbox", templateAttributes) | |
| 214 | |
| 215 def input_radiogroup(self, request, content, model, templateAttributes={}): | |
| 216 return self.input_group(request, content, model, model.choices, | |
| 217 "radio", templateAttributes) | |
| 218 | |
| 219 #I don't know why they're the same, but they were. So I removed the | |
| 220 #excess code. Maybe someone should look into removing it entirely. | |
| 221 input_flags = input_checkgroup | |
| 222 | |
| 223 def input_boolean(self, request, content, model, templateAttributes={}): | |
| 224 kw = {} | |
| 225 if self.getValue(request, model): | |
| 226 kw = {'checked' : '1'} | |
| 227 return content.input(type="checkbox", name=model.name, **kw) | |
| 228 | |
| 229 def input_file(self, request, content, model, templateAttributes={}): | |
| 230 kw = {} | |
| 231 for attrib in ['size', 'accept']: | |
| 232 val = model.getHint(attrib, templateAttributes.get(attrib, None)) | |
| 233 if val: | |
| 234 kw[attrib] = str(val) | |
| 235 return content.input(type="file", name=model.name, **kw) | |
| 236 | |
| 237 def input_date(self, request, content, model, templateAttributes={}): | |
| 238 breakLines = model.getHint('breaklines', 1) | |
| 239 date = self.getValues(request, model) | |
| 240 if date == None: | |
| 241 year, month, day = "", "", "" | |
| 242 else: | |
| 243 year, month, day = date | |
| 244 div = content.div() | |
| 245 div.text("Year: ") | |
| 246 div.input(type="text", size="4", maxlength="4", name=model.name, value=s
tr(year)) | |
| 247 if breakLines: | |
| 248 div.br() | |
| 249 div.text("Month: ") | |
| 250 div.input(type="text", size="2", maxlength="2", name=model.name, value=s
tr(month)) | |
| 251 if breakLines: | |
| 252 div.br() | |
| 253 div.text("Day: ") | |
| 254 div.input(type="text", size="2", maxlength="2", name=model.name, value=s
tr(day)) | |
| 255 return div | |
| 256 | |
| 257 def input_password(self, request, content, model, templateAttributes={}): | |
| 258 return content.input( | |
| 259 type="password", | |
| 260 size=str(templateAttributes.get('size', "60")), | |
| 261 name=model.name) | |
| 262 | |
| 263 def input_verifiedpassword(self, request, content, model, templateAttributes
={}): | |
| 264 breakLines = model.getHint('breaklines', 1) | |
| 265 values = self.getValues(request, model) | |
| 266 if isinstance(values, (str, unicode)): | |
| 267 values = (values, values) | |
| 268 if not values: | |
| 269 p1, p2 = "", "" | |
| 270 elif len(values) == 1: | |
| 271 p1, p2 = values, "" | |
| 272 elif len(values) == 2: | |
| 273 p1, p2 = values | |
| 274 else: | |
| 275 p1, p2 = "", "" | |
| 276 div = content.div() | |
| 277 div.text("Password: ") | |
| 278 div.input(type="password", size="20", name=model.name, value=str(p1)) | |
| 279 if breakLines: | |
| 280 div.br() | |
| 281 div.text("Verify: ") | |
| 282 div.input(type="password", size="20", name=model.name, value=str(p2)) | |
| 283 return div | |
| 284 | |
| 285 | |
| 286 def convergeInput(self, request, content, model, templateNode): | |
| 287 name = model.__class__.__name__.lower() | |
| 288 if _renderers.has_key(model.__class__): | |
| 289 imeth = _renderers[model.__class__] | |
| 290 else: | |
| 291 imeth = getattr(self,"input_"+name) | |
| 292 | |
| 293 return imeth(request, content, model, templateNode.attributes).node | |
| 294 | |
| 295 def createInput(self, request, shell, model, templateAttributes={}): | |
| 296 name = model.__class__.__name__.lower() | |
| 297 if _renderers.has_key(model.__class__): | |
| 298 imeth = _renderers[model.__class__] | |
| 299 else: | |
| 300 imeth = getattr(self,"input_"+name) | |
| 301 if name in self.SPANNING_TYPES: | |
| 302 td = shell.tr().td(valign="top", colspan="2") | |
| 303 return (imeth(request, td, model).node, shell.tr().td(colspan="2").n
ode) | |
| 304 else: | |
| 305 if model.allowNone: | |
| 306 required = "" | |
| 307 else: | |
| 308 required = " *" | |
| 309 tr = shell.tr() | |
| 310 tr.td(align="right", valign="top").text(model.getShortDescription()+
":"+required) | |
| 311 content = tr.td(valign="top") | |
| 312 return (imeth(request, content, model).node, | |
| 313 content.div(_class="formDescription"). # because class is a
keyword | |
| 314 text(model.getLongDescription()).node) | |
| 315 | |
| 316 def setUp(self, request, node, data): | |
| 317 # node = widgets.Widget.generateDOM(self,request,node) | |
| 318 lmn = lmx(node) | |
| 319 if not node.hasAttribute('action'): | |
| 320 lmn['action'] = (request.prepath+request.postpath)[-1] | |
| 321 if not node.hasAttribute("method"): | |
| 322 lmn['method'] = 'post' | |
| 323 lmn['enctype'] = 'multipart/form-data' | |
| 324 self.errorNodes = errorNodes = {} # name: nodes whic
h trap errors | |
| 325 self.inputNodes = inputNodes = {} | |
| 326 for errorNode in domhelpers.findElementsWithAttribute(node, 'errorFor'): | |
| 327 errorNodes[errorNode.getAttribute('errorFor')] = errorNode | |
| 328 argz={} | |
| 329 # list to figure out which nodes are in the template already and which a
ren't | |
| 330 hasSubmit = 0 | |
| 331 argList = self.model.fmethod.getArgs() | |
| 332 for arg in argList: | |
| 333 if isinstance(arg, formmethod.Submit): | |
| 334 hasSubmit = 1 | |
| 335 argz[arg.name] = arg | |
| 336 inNodes = domhelpers.findElements( | |
| 337 node, | |
| 338 lambda n: n.tagName.lower() in ('textarea', 'select', 'input', | |
| 339 'div')) | |
| 340 for inNode in inNodes: | |
| 341 t = inNode.getAttribute("type") | |
| 342 if t and t.lower() == "submit": | |
| 343 hasSubmit = 1 | |
| 344 if not inNode.hasAttribute("name"): | |
| 345 continue | |
| 346 nName = inNode.getAttribute("name") | |
| 347 if argz.has_key(nName): | |
| 348 #send an empty content shell - we just want the node | |
| 349 inputNodes[nName] = self.convergeInput(request, lmx(), | |
| 350 argz[nName], inNode) | |
| 351 inNode.parentNode.replaceChild(inputNodes[nName], inNode) | |
| 352 del argz[nName] | |
| 353 # TODO: | |
| 354 # * some arg types should only have a single node (text, string, etc
) | |
| 355 # * some should have multiple nodes (choice, checkgroup) | |
| 356 # * some have a bunch of ancillary nodes that are possible values (m
enu, radiogroup) | |
| 357 # these should all be taken into account when walking through the te
mplate | |
| 358 if argz: | |
| 359 shell = self.createShell(request, node, data) | |
| 360 # create inputs, in the same order they were passed to us: | |
| 361 for remArg in [arg for arg in argList if argz.has_key(arg.name)]: | |
| 362 inputNode, errorNode = self.createInput(request, shell, remArg) | |
| 363 errorNodes[remArg.name] = errorNode | |
| 364 inputNodes[remArg.name] = inputNode | |
| 365 | |
| 366 if not hasSubmit: | |
| 367 lmn.input(type="submit") | |
| 368 | |
| 369 | |
| 370 class FormErrorWidget(FormFillerWidget): | |
| 371 def setUp(self, request, node, data): | |
| 372 FormFillerWidget.setUp(self, request, node, data) | |
| 373 for k, f in self.model.err.items(): | |
| 374 en = self.errorNodes[k] | |
| 375 tn = self.inputNodes[k] | |
| 376 en.setAttribute('class', 'formError') | |
| 377 tn.setAttribute('class', 'formInputError') | |
| 378 en.childNodes[:]=[] # gurfle, CLEAR IT NOW!@# | |
| 379 if isinstance(f, failure.Failure): | |
| 380 f = f.getErrorMessage() | |
| 381 lmx(en).text(str(f)) | |
| 382 | |
| 383 | |
| 384 class FormDisplayModel(model.MethodModel): | |
| 385 def initialize(self, fmethod, alwaysDefault=False): | |
| 386 self.fmethod = fmethod | |
| 387 self.alwaysDefault = alwaysDefault | |
| 388 | |
| 389 class FormErrorModel(FormDisplayModel): | |
| 390 def initialize(self, fmethod, args, err): | |
| 391 FormDisplayModel.initialize(self, fmethod) | |
| 392 self.args = args | |
| 393 if isinstance(err, failure.Failure): | |
| 394 err = err.value | |
| 395 if isinstance(err, Exception): | |
| 396 self.err = getattr(err, "descriptions", {}) | |
| 397 self.desc = err | |
| 398 else: | |
| 399 self.err = err | |
| 400 self.desc = "Please try again" | |
| 401 | |
| 402 def wmfactory_description(self, request): | |
| 403 return str(self.desc) | |
| 404 | |
| 405 class _RequestHack(model.MethodModel): | |
| 406 def wmfactory_hack(self, request): | |
| 407 rv = [[str(a), repr(b)] for (a, b) | |
| 408 in request._outDict.items()] | |
| 409 #print 'hack', rv | |
| 410 return rv | |
| 411 | |
| 412 class FormProcessor(resource.Resource): | |
| 413 def __init__(self, formMethod, callback=None, errback=None): | |
| 414 resource.Resource.__init__(self) | |
| 415 self.formMethod = formMethod | |
| 416 if callback is None: | |
| 417 callback = self.viewFactory | |
| 418 self.callback = callback | |
| 419 if errback is None: | |
| 420 errback = self.errorViewFactory | |
| 421 self.errback = errback | |
| 422 | |
| 423 def getArgs(self, request): | |
| 424 """Return the formmethod.Arguments. | |
| 425 | |
| 426 Overridable hook to allow pre-processing, e.g. if we want to enable | |
| 427 on them depending on one of the inputs. | |
| 428 """ | |
| 429 return self.formMethod.getArgs() | |
| 430 | |
| 431 def render(self, request): | |
| 432 outDict = {} | |
| 433 errDict = {} | |
| 434 for methodArg in self.getArgs(request): | |
| 435 valmethod = getattr(self,"mangle_"+ | |
| 436 (methodArg.__class__.__name__.lower()), None) | |
| 437 tmpval = request.args.get(methodArg.name) | |
| 438 if valmethod: | |
| 439 # mangle the argument to a basic datatype that coerce will like | |
| 440 tmpval = valmethod(tmpval) | |
| 441 # coerce it | |
| 442 try: | |
| 443 cv = methodArg.coerce(tmpval) | |
| 444 outDict[methodArg.name] = cv | |
| 445 except: | |
| 446 errDict[methodArg.name] = failure.Failure() | |
| 447 if errDict: | |
| 448 # there were problems processing the form | |
| 449 return self.errback(self.errorModelFactory( | |
| 450 request.args, outDict, errDict)).render(request) | |
| 451 else: | |
| 452 try: | |
| 453 if self.formMethod.takesRequest: | |
| 454 outObj = self.formMethod.call(request=request, **outDict) | |
| 455 else: | |
| 456 outObj = self.formMethod.call(**outDict) | |
| 457 except formmethod.FormException, e: | |
| 458 err = request.errorInfo = self.errorModelFactory( | |
| 459 request.args, outDict, e) | |
| 460 return self.errback(err).render(request) | |
| 461 else: | |
| 462 request._outDict = outDict # CHOMP CHOMP! | |
| 463 # I wanted better default behavior for debugging, so I could | |
| 464 # see the arguments passed, but there is no channel for this in | |
| 465 # the existing callback structure. So, here it goes. | |
| 466 if isinstance(outObj, defer.Deferred): | |
| 467 def _ebModel(err): | |
| 468 if err.trap(formmethod.FormException): | |
| 469 mf = self.errorModelFactory(request.args, outDict, | |
| 470 err.value) | |
| 471 return self.errback(mf) | |
| 472 raise err | |
| 473 (outObj | |
| 474 .addCallback(self.modelFactory) | |
| 475 .addCallback(self.callback) | |
| 476 .addErrback(_ebModel)) | |
| 477 return util.DeferredResource(outObj).render(request) | |
| 478 else: | |
| 479 return self.callback(self.modelFactory(outObj)).render( | |
| 480 request) | |
| 481 | |
| 482 def errorModelFactory(self, args, out, err): | |
| 483 return FormErrorModel(self.formMethod, args, err) | |
| 484 | |
| 485 def errorViewFactory(self, m): | |
| 486 v = view.View(m) | |
| 487 v.template = ''' | |
| 488 <html> | |
| 489 <head> | |
| 490 <title> Form Error View </title> | |
| 491 <style> | |
| 492 .formDescription {color: green} | |
| 493 .formError {color: red; font-weight: bold} | |
| 494 .formInputError {color: #900} | |
| 495 </style> | |
| 496 </head> | |
| 497 <body> | |
| 498 Error: <span model="description" /> | |
| 499 <form model="."> | |
| 500 </form> | |
| 501 </body> | |
| 502 </html> | |
| 503 ''' | |
| 504 return v | |
| 505 | |
| 506 def modelFactory(self, outObj): | |
| 507 adapt = interfaces.IModel(outObj, outObj) | |
| 508 # print 'factorizing', adapt | |
| 509 return adapt | |
| 510 | |
| 511 def viewFactory(self, model): | |
| 512 # return interfaces.IView(model) | |
| 513 if model is None: | |
| 514 bodyStr = ''' | |
| 515 <table model="hack" style="background-color: #99f"> | |
| 516 <tr pattern="listItem" view="Widget"> | |
| 517 <td model="0" style="font-weight: bold"> | |
| 518 </td> | |
| 519 <td model="1"> | |
| 520 </td> | |
| 521 </tr> | |
| 522 </table> | |
| 523 ''' | |
| 524 model = _RequestHack() | |
| 525 else: | |
| 526 bodyStr = '<div model="." />' | |
| 527 v = view.View(model) | |
| 528 v.template = ''' | |
| 529 <html> | |
| 530 <head> | |
| 531 <title> Thank You </title> | |
| 532 </head> | |
| 533 <body> | |
| 534 <h1>Thank You for Using Woven</h1> | |
| 535 %s | |
| 536 </body> | |
| 537 </html> | |
| 538 ''' % bodyStr | |
| 539 return v | |
| 540 | |
| 541 # manglizers | |
| 542 | |
| 543 def mangle_single(self, args): | |
| 544 if args: | |
| 545 return args[0] | |
| 546 else: | |
| 547 return '' | |
| 548 | |
| 549 mangle_string = mangle_single | |
| 550 mangle_text = mangle_single | |
| 551 mangle_integer = mangle_single | |
| 552 mangle_password = mangle_single | |
| 553 mangle_integerrange = mangle_single | |
| 554 mangle_float = mangle_single | |
| 555 mangle_choice = mangle_single | |
| 556 mangle_boolean = mangle_single | |
| 557 mangle_hidden = mangle_single | |
| 558 mangle_submit = mangle_single | |
| 559 mangle_file = mangle_single | |
| 560 mangle_radiogroup = mangle_single | |
| 561 | |
| 562 def mangle_multi(self, args): | |
| 563 if args is None: | |
| 564 return [] | |
| 565 return args | |
| 566 | |
| 567 mangle_checkgroup = mangle_multi | |
| 568 mangle_flags = mangle_multi | |
| 569 | |
| 570 from twisted.python.formmethod import FormMethod | |
| 571 | |
| 572 view.registerViewForModel(FormFillerWidget, FormDisplayModel) | |
| 573 view.registerViewForModel(FormErrorWidget, FormErrorModel) | |
| 574 registerAdapter(FormDisplayModel, FormMethod, interfaces.IModel) | |
| 575 | |
| OLD | NEW |