source: main/waeup.sirp/trunk/src/waeup/sirp/interfaces.py @ 7339

Last change on this file since 7339 was 7323, checked in by uli, 13 years ago
  • Add testing captcha as option in config.
  • Make phone number a textline instead of int.

Phone numbers often begin with a leading zero and it makes a
difference whether there are one or more leading zeros. If we store
phone numbers as numbers (no address manager application does it this
way) we strip off this digits and get insufficient information for
instance for any future SMS support. Phone numbers therefore should be
stored as text lines and not as ints.

  • Property svn:eol-style set to native
  • Property svn:keywords set to Id
File size: 25.3 KB
Line 
1## $Id: interfaces.py 7323 2011-12-10 09:33:46Z uli $
2##
3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18import os
19import re
20from datetime import datetime
21from hurry.file.interfaces import IFileRetrieval
22from hurry.workflow.interfaces import IWorkflow, IWorkflowInfo
23from zc.sourcefactory.basic import BasicSourceFactory
24from zope import schema
25from zope.pluggableauth.interfaces import IPrincipalInfo
26from zope.security.interfaces import IGroupClosureAwarePrincipal as IPrincipal
27from zope.component import getUtility
28from zope.component.interfaces import IObjectEvent
29from zope.container.interfaces import INameChooser
30from zope.interface import Interface, Attribute, implements
31from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
32
33CREATED = 'created'
34ADMITTED = 'admitted'
35CLEARANCE = 'clearance started'
36REQUESTED = 'clearance requested'
37CLEARED = 'cleared'
38PAID = 'school fee paid'
39RETURNING = 'returning'
40REGISTERED = 'courses registered'
41VALIDATED = 'courses validated'
42
43default_frontpage = u'' + open(os.path.join(
44        os.path.dirname(__file__), 'frontpage.rst'), 'rb').read()
45
46def SimpleSIRPVocabulary(*terms):
47    """A well-buildt vocabulary provides terms with a value, token and
48       title for each term
49    """
50    return SimpleVocabulary([
51            SimpleTerm(value, value, title) for title, value in terms])
52
53def year_range():
54    curr_year = datetime.now().year
55    return range(curr_year - 2, curr_year + 5)
56
57def academic_sessions():
58    curr_year = datetime.now().year
59    year_range = range(curr_year - 10, curr_year + 2)
60    return [('%s/%s' % (year,year+1), year) for year in year_range]
61
62academic_sessions_vocab = SimpleSIRPVocabulary(*academic_sessions())
63
64registration_states_vocab = SimpleSIRPVocabulary(
65    ('created', CREATED),
66    ('admitted', ADMITTED),
67    ('clearance started', CLEARANCE),
68    ('clearance requested', REQUESTED),
69    ('cleared', CLEARED),
70    ('school fee paid', PAID),
71    ('returning', RETURNING),
72    ('courses registered', REGISTERED),
73    ('courses validated', VALIDATED),
74    )
75
76# Define a valiation method for email addresses
77class NotAnEmailAddress(schema.ValidationError):
78    __doc__ = u"Invalid email address"
79
80check_email = re.compile(
81    r"[a-zA-Z0-9._%-]+@([a-zA-Z0-9-]+.)*[a-zA-Z]{2,4}").match
82
83def validate_email(value):
84    if not check_email(value):
85        raise NotAnEmailAddress(value)
86    return True
87
88class FatalCSVError(Exception):
89    """Some row could not be processed.
90    """
91    pass
92
93class DuplicationError(Exception):
94    """An exception that can be raised when duplicates are found.
95
96    When raising :exc:`DuplicationError` you can, beside the usual
97    message, specify a list of objects which are duplicates. These
98    values can be used by catching code to print something helpful or
99    similar.
100    """
101    def __init__(self, msg, entries=[]):
102        self.msg = msg
103        self.entries = entries
104
105    def __str__(self):
106        return '%r' % self.msg
107
108class RoleSource(BasicSourceFactory):
109    """A source for site roles.
110    """
111    def getValues(self):
112        # late import: in interfaces we should not import local modules
113        from waeup.sirp.permissions import get_waeup_role_names
114        return get_waeup_role_names()
115
116    def getTitle(self, value):
117        # late import: in interfaces we should not import local modules
118        from waeup.sirp.permissions import get_all_roles
119        roles = dict(get_all_roles())
120        if value in roles.keys():
121            title = roles[value].title
122            if '.' in title:
123                title = title.split('.', 2)[1]
124        return title
125
126class CaptchaSource(BasicSourceFactory):
127    """A source for captchas.
128    """
129    def getValues(self):
130        captchas = ['No captcha', 'Testing captcha', 'ReCaptcha']
131        try:
132            # we have to 'try' because IConfiguration can only handle
133            # interfaces from w.s.interface.
134            from waeup.sirp.browser.interfaces import ICaptchaManager
135        except:
136            return captchas
137        return sorted(getUtility(ICaptchaManager).getAvailCaptchas().keys())
138
139    def getTitle(self, value):
140        return value
141
142class ISIRPObject(Interface):
143    """A SIRP object.
144
145    This is merely a marker interface.
146    """
147
148class IUniversity(ISIRPObject):
149    """Representation of a university.
150    """
151
152
153class ISIRPContainer(ISIRPObject):
154    """A container for SIRP objects.
155    """
156
157class ISIRPContained(ISIRPObject):
158    """An item contained in an ISIRPContainer.
159    """
160
161class ISIRPExporter(Interface):
162    """An exporter for objects.
163    """
164    def export(obj, filepath=None):
165        """Export by pickling.
166
167        Returns a file-like object containing a representation of `obj`.
168
169        This is done using `pickle`. If `filepath` is ``None``, a
170        `cStringIO` object is returned, that contains the saved data.
171        """
172
173class ISIRPXMLExporter(Interface):
174    """An XML exporter for objects.
175    """
176    def export(obj, filepath=None):
177        """Export as XML.
178
179        Returns an XML representation of `obj`.
180
181        If `filepath` is ``None``, a StringIO` object is returned,
182        that contains the transformed data.
183        """
184
185class ISIRPXMLImporter(Interface):
186    """An XML import for objects.
187    """
188    def doImport(filepath):
189        """Create Python object from XML.
190
191        Returns a Python object.
192        """
193
194class IBatchProcessor(Interface):
195    """A batch processor that handles mass-operations.
196    """
197    name = schema.TextLine(
198        title = u'Importer name'
199        )
200
201    mode = schema.Choice(
202        title = u'Import mode',
203        values = ['create', 'update', 'remove']
204        )
205
206    def doImport(path, headerfields, mode='create', user='Unknown',
207                 logger=None):
208        """Read data from ``path`` and update connected object.
209
210        `headerfields` is a list of headerfields as read from the file
211        to import.
212
213        `mode` gives the import mode to use (``'create'``,
214        ``'update'``, or ``'remove'``.
215
216        `user` is a string describing the user performing the
217        import. Normally fetched from current principal.
218
219        `logger` is the logger to use during import.
220        """
221
222class IContactForm(ISIRPObject):
223    """A contact form.
224    """
225
226    email_from = schema.ASCIILine(
227        title = u'Email Address:',
228        default = None,
229        required = True,
230        constraint=validate_email,
231        )
232
233    email_to = schema.ASCIILine(
234        title = u'Email to:',
235        default = None,
236        required = True,
237        constraint=validate_email,
238        )
239
240    subject = schema.TextLine(
241        title = u'Subject:',
242        required = True,)
243
244    fullname = schema.TextLine(
245        title = u'Full Name:',
246        required = True,)
247
248    body = schema.Text(
249        title = u'Text:',
250        required = True,)
251
252class ISIRPPrincipalInfo(IPrincipalInfo):
253    """Infos about principals that are users of SIRP SIRP.
254    """
255    email = Attribute("The email address of a user")
256    phone = Attribute("The phone number of a user")
257
258
259class ISIRPPrincipal(IPrincipal):
260    """A principle for SIRP SIRP.
261
262    This interface extends zope.security.interfaces.IPrincipal and
263    requires also an `id` and other attributes defined there.
264    """
265
266    email = schema.TextLine(
267        title = u'Email',
268        description = u'',
269        required=False,)
270
271    phone = schema.TextLine(
272        title = u'Phone',
273        description = u'',
274        required=False,)
275
276class IUserAccount(ISIRPObject):
277    """A user account.
278    """
279    name = schema.TextLine(
280        title = u'User ID',
281        description = u'Login name of user',
282        required = True,)
283
284    title = schema.TextLine(
285        title = u'Name',
286        description = u'Real name of user',
287        required = False,)
288
289    description = schema.Text(
290        title = u'Description/Notice',
291        required = False,)
292
293    email = schema.ASCIILine(
294        title = u'Email',
295        default = None,
296        required = True,
297        constraint=validate_email,
298        )
299
300    phone = schema.TextLine(
301        title = u'Phone',
302        default = None,
303        required = True,
304        )
305
306    roles = schema.List(
307        title = u'Portal roles',
308        value_type = schema.Choice(source=RoleSource()))
309
310class IPasswordValidator(Interface):
311    """A password validator utility.
312    """
313
314    def validate_password(password, password_repeat):
315        """Validates a password by comparing it with
316        control password and checking some other requirements.
317        """
318
319
320class IUsersContainer(ISIRPObject):
321    """A container for users (principals).
322
323    These users are used for authentication purposes.
324    """
325
326    def addUser(name, password, title=None, description=None):
327        """Add a user.
328        """
329
330    def delUser(name):
331        """Delete a user if it exists.
332        """
333
334class ILocalRolesAssignable(Interface):
335    """The local roles assignable to an object.
336    """
337    def __call__():
338        """Returns a list of dicts.
339
340        Each dict contains a ``name`` referring to the role assignable
341        for the specified object and a `title` to describe the range
342        of users to which this role can be assigned.
343        """
344
345class IConfigurationContainer(ISIRPObject):
346    """A container for session configuration objects.
347    """
348
349    name = schema.TextLine(
350        title = u'Name of University',
351        default = u'Sample University',
352        required = True,
353        )
354
355    title = schema.TextLine(
356        title = u'Title of Frontpage',
357        default = u'Welcome to the Student Information and Registration ' +
358                  u'Portal of Sample University',
359        required = False,
360        )
361
362    skin = schema.Choice(
363        title = u'Skin',
364        default = u'gray waeup theme',
365        vocabulary = 'waeup.sirp.browser.theming.ThemesVocabulary',
366        required = True,
367        )
368
369    frontpage = schema.Text(
370        title = u'Content in reST format',
371        required = False,
372        default = default_frontpage,
373        )
374
375    accommodation_session = schema.Choice(
376        title = u'Accommodation Booking Session',
377        source = academic_sessions_vocab,
378        default = datetime.now().year,
379        required = False,
380        readonly = False,
381        )
382
383    accommodation_states = schema.List(
384        title = u'Allowed States for Accommodation Booking',
385        value_type = schema.Choice(
386            vocabulary = registration_states_vocab,
387            ),
388        default = [],
389        )
390
391    name_admin = schema.TextLine(
392        title = u'Name of Administrator',
393        default = u'Administrator',
394        required = False,
395        )
396
397    email_admin = schema.ASCIILine(
398        title = u'Email Address of Administrator',
399        default = 'contact@waeup.org',
400        required = False,
401        constraint=validate_email,
402        )
403
404    email_subject = schema.TextLine(
405        title = u'Subject of Email to Administrator',
406        default = u'SIRP Contact',
407        required = False,
408        )
409
410    smtp_server = schema.TextLine(
411        title = u'Adress of SMTP Server',
412        default = u'localhost',
413        required = False,
414        )
415
416    smtp_requires_login = schema.Bool(
417        title = u'Server requires login',
418        default = False,
419        )
420
421    smtp_username = schema.TextLine(
422        title = u'Username for SMTP Account',
423        default = None,
424        required = False,
425        )
426
427    smtp_password = schema.Password(
428        title = u'Password for SMTP Account',
429        default = None,
430        required = False,
431        )
432
433    captcha = schema.Choice(
434        title = u'Captcha used for applicant registration pages',
435        source = CaptchaSource(),
436        default = u'No captcha',
437        required = True,
438        )
439
440class ISessionConfiguration(ISIRPObject):
441    """A session configuration object.
442    """
443
444    academic_session = schema.Choice(
445        title = u'Academic Session',
446        source = academic_sessions_vocab,
447        default = None,
448        required = True,
449        readonly = True,
450        )
451
452    school_fee_base = schema.Int(
453        title = u'School Fee',
454        default = 0,
455        )
456
457    surcharge_1 = schema.Int(
458        title = u'Surcharge 1',
459        default = 0,
460        )
461
462    surcharge_2 = schema.Int(
463        title = u'Surcharge 2',
464        default = 0,
465        )
466
467    surcharge_3 = schema.Int(
468        title = u'Surcharge 3',
469        default = 0,
470        )
471
472    clearance_fee = schema.Int(
473        title = u'Clearance Fee',
474        default = 0,
475        )
476
477    booking_fee = schema.Int(
478        title = u'Booking Fee',
479        default = 0,
480        )
481
482    acceptance_fee = schema.Int(
483        title = u'Acceptance Fee',
484        default = 0,
485        )
486
487    def getSessionString():
488        """Returns the session string from the vocabulary.
489        """
490
491
492class ISessionConfigurationAdd(ISessionConfiguration):
493    """A session configuration object in add mode.
494    """
495
496    academic_session = schema.Choice(
497        title = u'Academic Session',
498        source = academic_sessions_vocab,
499        default = None,
500        required = True,
501        readonly = False,
502        )
503
504ISessionConfigurationAdd['academic_session'].order =  ISessionConfiguration[
505    'academic_session'].order
506
507class IDataCenter(ISIRPObject):
508    """A data center.
509
510    TODO : declare methods, at least those needed by pages.
511    """
512    pass
513
514class IDataCenterFile(Interface):
515    """A data center file.
516    """
517
518    name = schema.TextLine(
519        title = u'Filename')
520
521    size = schema.TextLine(
522        title = u'Human readable file size')
523
524    uploaddate = schema.TextLine(
525        title = u'Human readable upload datetime')
526
527    lines = schema.Int(
528        title = u'Number of lines in file')
529
530    def getDate():
531        """Get creation timestamp from file in human readable form.
532        """
533
534    def getSize():
535        """Get human readable size of file.
536        """
537
538    def getLinesNumber():
539        """Get number of lines of file.
540        """
541
542class IDataCenterStorageMovedEvent(IObjectEvent):
543    """Emitted, when the storage of a datacenter changes.
544    """
545
546class IObjectUpgradeEvent(IObjectEvent):
547    """Can be fired, when an object shall be upgraded.
548    """
549
550class ILocalRoleSetEvent(IObjectEvent):
551    """A local role was granted/revoked for a principal on an object.
552    """
553    role_id = Attribute(
554        "The role id that was set.")
555    principal_id = Attribute(
556        "The principal id for which the role was granted/revoked.")
557    granted = Attribute(
558        "Boolean. If false, then the role was revoked.")
559
560class IQueryResultItem(Interface):
561    """An item in a search result.
562    """
563    url = schema.TextLine(
564        title = u'URL that links to the found item')
565    title = schema.TextLine(
566        title = u'Title displayed in search results.')
567    description = schema.Text(
568        title = u'Longer description of the item found.')
569
570class ISIRPPluggable(Interface):
571    """A component that might be plugged into a SIRP SIRP app.
572
573    Components implementing this interface are referred to as
574    'plugins'. They are normally called when a new
575    :class:`waeup.sirp.app.University` instance is created.
576
577    Plugins can setup and update parts of the central site without the
578    site object (normally a :class:`waeup.sirp.app.University` object)
579    needing to know about that parts. The site simply collects all
580    available plugins, calls them and the plugins care for their
581    respective subarea like the applicants area or the datacenter
582    area.
583
584    Currently we have no mechanism to define an order of plugins. A
585    plugin should therefore make no assumptions about the state of the
586    site or other plugins being run before and instead do appropriate
587    checks if necessary.
588
589    Updates can be triggered for instance by the respective form in
590    the site configuration. You normally do updates when the
591    underlying software changed.
592    """
593    def setup(site, name, logger):
594        """Create an instance of the plugin.
595
596        The method is meant to be called by the central app (site)
597        when it is created.
598
599        `site`:
600           The site that requests a setup.
601
602        `name`:
603           The name under which the plugin was registered (utility name).
604
605        `logger`:
606           A standard Python logger for the plugins use.
607        """
608
609    def update(site, name, logger):
610        """Method to update an already existing plugin.
611
612        This might be called by a site when something serious
613        changes. It is a poor-man replacement for Zope generations
614        (but probably more comprehensive and better understandable).
615
616        `site`:
617           The site that requests an update.
618
619        `name`:
620           The name under which the plugin was registered (utility name).
621
622        `logger`:
623           A standard Python logger for the plugins use.
624        """
625
626class IAuthPluginUtility(Interface):
627    """A component that cares for authentication setup at site creation.
628
629    Utilities providing this interface are looked up when a Pluggable
630    Authentication Utility (PAU) for any
631    :class:`waeup.sirp.app.University` instance is created and put
632    into ZODB.
633
634    The setup-code then calls the `register` method of the utility and
635    expects a modified (or unmodified) version of the PAU back.
636
637    This allows to define any authentication setup modifications by
638    submodules or third-party modules/packages.
639    """
640
641    def register(pau):
642        """Register any plugins wanted to be in the PAU.
643        """
644
645    def unregister(pau):
646        """Unregister any plugins not wanted to be in the PAU.
647        """
648
649class IObjectConverter(Interface):
650    """Object converters are available as simple adapters, adapting
651       interfaces (not regular instances).
652
653    """
654
655    def fromStringDict(self, data_dict, context, form_fields=None):
656        """Convert values in `data_dict`.
657
658        Converts data in `data_dict` into real values based on
659        `context` and `form_fields`.
660
661        `data_dict` is a mapping (dict) from field names to values
662        represented as strings.
663
664        The fields (keys) to convert can be given in optional
665        `form_fields`. If given, form_fields should be an instance of
666        :class:`zope.formlib.form.Fields`. Suitable instances are for
667        example created by :class:`grok.AutoFields`.
668
669        If no `form_fields` are given, a default is computed from the
670        associated interface.
671
672        The `context` can be an existing object (implementing the
673        associated interface) or a factory name. If it is a string, we
674        try to create an object using
675        :func:`zope.component.createObject`.
676
677        Returns a tuple ``(<FIELD_ERRORS>, <INVARIANT_ERRORS>,
678        <DATA_DICT>)`` where
679
680        ``<FIELD_ERRORS>``
681           is a list of tuples ``(<FIELD_NAME>, <ERROR>)`` for each
682           error that happened when validating the input data in
683           `data_dict`
684
685        ``<INVARIANT_ERRORS>``
686           is a list of invariant errors concerning several fields
687
688        ``<DATA_DICT>``
689           is a dict with the values from input dict converted.
690
691        If errors happen, i.e. the error lists are not empty, always
692        an empty ``<DATA_DICT>`` is returned.
693
694        If ``<DATA_DICT>` is non-empty, there were no errors.
695        """
696
697class IObjectHistory(Interface):
698
699    messages = schema.List(
700        title = u'List of messages stored',
701        required = True,
702        )
703
704    def addMessage(message):
705        """Add a message.
706        """
707
708class ISIRPWorkflowInfo(IWorkflowInfo):
709    """A :class:`hurry.workflow.workflow.WorkflowInfo` with additional
710       methods for convenience.
711    """
712    def getManualTransitions():
713        """Get allowed manual transitions.
714
715        Get a sorted list of tuples containing the `transition_id` and
716        `title` of each allowed transition.
717        """
718
719class ISiteLoggers(Interface):
720
721    loggers = Attribute("A list or generator of registered SIRPLoggers")
722
723    def register(name, filename=None, site=None, **options):
724        """Register a logger `name` which logs to `filename`.
725
726        If `filename` is not given, logfile will be `name` with
727        ``.log`` as filename extension.
728        """
729
730    def unregister(name):
731        """Unregister a once registered logger.
732        """
733
734class ILogger(Interface):
735    """A logger cares for setup, update and restarting of a Python logger.
736    """
737
738    logger = Attribute("""A :class:`logging.Logger` instance""")
739
740
741    def __init__(name, filename=None, site=None, **options):
742        """Create a SIRP logger instance.
743        """
744
745    def setup():
746        """Create a Python :class:`logging.Logger` instance.
747
748        The created logger is based on the params given by constructor.
749        """
750
751    def update(**options):
752        """Update the logger.
753
754        Updates the logger respecting modified `options` and changed
755        paths.
756        """
757
758class ILoggerCollector(Interface):
759
760    def getLoggers(site):
761        """Return all loggers registered for `site`.
762        """
763
764    def registerLogger(site, logging_component):
765        """Register a logging component residing in `site`.
766        """
767
768    def unregisterLogger(site, logging_component):
769        """Unregister a logger.
770        """
771
772#
773# External File Storage and relatives
774#
775class IFileStoreNameChooser(INameChooser):
776    """See zope.container.interfaces.INameChooser for base methods.
777    """
778    def checkName(name, attr=None):
779        """Check whether an object name is valid.
780
781        Raises a user error if the name is not valid.
782        """
783
784    def chooseName(name, attr=None):
785        """Choose a unique valid file id for the object.
786
787        The given name may be taken into account when choosing the
788        name (file id).
789
790        chooseName is expected to always choose a valid file id (that
791        would pass the checkName test) and never raise an error.
792
793        If `attr` is not ``None`` it might been taken into account as
794        well when generating the file id. Usual behaviour is to
795        interpret `attr` as a hint for what type of file for a given
796        context should be stored if there are several types
797        possible. For instance for a certain student some file could
798        be the connected passport photograph or some certificate scan
799        or whatever. Each of them has to be stored in a different
800        location so setting `attr` to a sensible value should give
801        different file ids returned.
802        """
803
804class IExtFileStore(IFileRetrieval):
805    """A file storage that stores files in filesystem (not as blobs).
806    """
807    root = schema.TextLine(
808        title = u'Root path of file store.',
809        )
810
811    def getFile(file_id):
812        """Get raw file data stored under file with `file_id`.
813
814        Returns a file descriptor open for reading or ``None`` if the
815        file cannot be found.
816        """
817
818    def getFileByContext(context, attr=None):
819        """Get raw file data stored for the given context.
820
821        Returns a file descriptor open for reading or ``None`` if no
822        such file can be found.
823
824        Both, `context` and `attr` might be used to find (`context`)
825        and feed (`attr`) an appropriate file name chooser.
826
827        This is a convenience method.
828        """
829
830    def deleteFile(file_id):
831        """Delete file stored under `file_id`.
832
833        Remove file from filestore so, that it is not available
834        anymore on next call to getFile for the same file_id.
835
836        Should not complain if no such file exists.
837        """
838
839    def deleteFileByContext(context, attr=None):
840        """Delete file for given `context` and `attr`.
841
842        Both, `context` and `attr` might be used to find (`context`)
843        and feed (`attr`) an appropriate file name chooser.
844
845        This is a convenience method.
846        """
847
848    def createFile(filename, f):
849        """Create file given by f with filename `filename`
850
851        Returns a hurry.file.File-based object.
852        """
853
854class IFileStoreHandler(Interface):
855    """Filestore handlers handle specific files for file stores.
856
857    If a file to store/get provides a specific filename, a file store
858    looks up special handlers for that type of file.
859
860    """
861    def pathFromFileID(store, root, filename):
862        """Turn file id into path to store.
863
864        Returned path should be absolute.
865        """
866
867    def createFile(store, root, filename, file_id, file):
868        """Return some hurry.file based on `store` and `file_id`.
869
870        Some kind of callback method called by file stores to create
871        file objects from file_id.
872
873        Returns a tuple ``(raw_file, path, file_like_obj)`` where the
874        ``file_like_obj`` should be a HurryFile, a SIRPImageFile or
875        similar. ``raw_file`` is the (maybe changed) input file and
876        ``path`` the relative internal path to store the file at.
877
878        Please make sure the ``raw_file`` is opened for reading and
879        the file descriptor set at position 0 when returned.
880
881        This method also gets the raw input file object that is about
882        to be stored and is expected to raise any exceptions if some
883        kind of validation or similar fails.
884        """
Note: See TracBrowser for help on using the repository browser.