New translation system, that generates screens for updating translations through the web, within the configuration.
This commit is contained in:
parent
f3604624de
commit
ead9f7c2de
|
@ -1 +1 @@
|
|||
0.6.1
|
||||
0.6.2
|
||||
|
|
|
@ -70,7 +70,8 @@ class Page:
|
|||
showAttr = 'show%s' % elem.capitalize()
|
||||
# Get the value of the show attribute as identified above.
|
||||
show = getattr(self, showAttr)
|
||||
if callable(show): show = show(obj.appy())
|
||||
if callable(show):
|
||||
show = show(obj.appy())
|
||||
# Show value can be 'view', for example. Thanks to p_layoutType,
|
||||
# convert show value to a real final boolean value.
|
||||
res = show
|
||||
|
@ -407,7 +408,7 @@ class Type:
|
|||
# permission (string) instead of assigning "True" to the following
|
||||
# arg(s). A named permission will be global to your whole Zope site, so
|
||||
# take care to the naming convention. Typically, a named permission is
|
||||
# of the form: "<yourAppName>: Write|Read xxx". If, for example, I want
|
||||
# of the form: "<yourAppName>: Write|Read ---". If, for example, I want
|
||||
# to define, for my application "MedicalFolder" a specific permission
|
||||
# for a bunch of fields that can only be modified by a doctor, I can
|
||||
# define a permission "MedicalFolder: Write medical information" and
|
||||
|
@ -531,7 +532,7 @@ class Type:
|
|||
return False
|
||||
# Evaluate self.show
|
||||
if callable(self.show):
|
||||
res = self.show(obj.appy())
|
||||
res = self.callMethod(obj, self.show)
|
||||
else:
|
||||
res = self.show
|
||||
# Take into account possible values 'view' and 'edit' for 'show' param.
|
||||
|
@ -663,6 +664,15 @@ class Type:
|
|||
default layouts. If None is returned, a global set of default layouts
|
||||
will be used.'''
|
||||
|
||||
def getInputLayouts(self):
|
||||
'''Gets, as a string, the layouts as could have been specified as input
|
||||
value for the Type constructor.'''
|
||||
res = '{'
|
||||
for k, v in self.layouts.iteritems():
|
||||
res += '"%s":"%s",' % (k, v['layoutString'])
|
||||
res += '}'
|
||||
return res
|
||||
|
||||
def computeDefaultLayouts(self):
|
||||
'''This method gets the default layouts from an Appy type, or a copy
|
||||
from the global default field layouts when they are not available.'''
|
||||
|
@ -687,13 +697,12 @@ class Type:
|
|||
# If there is no value, get the default value if any
|
||||
if not self.editDefault:
|
||||
# Return self.default, of self.default() if it is a method
|
||||
if type(self.default) == types.FunctionType:
|
||||
appyObj = obj.appy()
|
||||
if callable(self.default):
|
||||
try:
|
||||
return self.default(appyObj)
|
||||
return self.callMethod(obj, self.default,
|
||||
raiseOnError=True)
|
||||
except Exception, e:
|
||||
appyObj.log('Exception while getting default value ' \
|
||||
'of field "%s": %s.' % (self.name, str(e)))
|
||||
# Already logged.
|
||||
return None
|
||||
else:
|
||||
return self.default
|
||||
|
@ -839,11 +848,35 @@ class Type:
|
|||
res.specificReadPermission = False
|
||||
res.specificWritePermission = False
|
||||
res.multiplicity = (0, self.multiplicity[1])
|
||||
if type(res.validator) == types.FunctionType:
|
||||
if callable(res.validator):
|
||||
# We will not be able to call this function from the tool.
|
||||
res.validator = None
|
||||
return res
|
||||
|
||||
def callMethod(self, obj, method, raiseOnError=False):
|
||||
'''This method is used to call a p_method on p_obj. p_method is part of
|
||||
this type definition (ie a default method, the method of a Computed
|
||||
field, a method used for showing or not a field...). Normally, those
|
||||
methods are called without any arg. But one may need, within the
|
||||
method, to access the related field. This method tries to call
|
||||
p_method with no arg *or* with the field arg.'''
|
||||
obj = obj.appy()
|
||||
try:
|
||||
return method(obj)
|
||||
except TypeError, te:
|
||||
# Try a version of the method that would accept self as an
|
||||
# additional parameter.
|
||||
try:
|
||||
return method(obj, self)
|
||||
except Exception, e:
|
||||
obj.log(Traceback.get(), type='error')
|
||||
if raiseOnError: raise e
|
||||
else: return str(e)
|
||||
except Exception, e:
|
||||
obj.log(Traceback.get(), type='error')
|
||||
if raiseOnError: raise e
|
||||
else: return str(e)
|
||||
|
||||
class Integer(Type):
|
||||
def __init__(self, validator=None, multiplicity=(0,1), index=None,
|
||||
default=None, optional=False, editDefault=False, show=True,
|
||||
|
@ -1139,6 +1172,10 @@ class String(Type):
|
|||
res = unicode(value)
|
||||
except UnicodeDecodeError:
|
||||
res = str(value)
|
||||
# If value starts with a carriage return, add a space; else, it will
|
||||
# be ignored.
|
||||
if isinstance(res, basestring) and \
|
||||
(res.startswith('\n') or res.startswith('\r\n')): res = ' ' + res
|
||||
return res
|
||||
|
||||
def getIndexValue(self, obj, forSearch=False):
|
||||
|
@ -1719,12 +1756,7 @@ class Computed(Type):
|
|||
def getValue(self, obj):
|
||||
'''Computes the value instead of getting it in the database.'''
|
||||
if not self.method: return
|
||||
obj = obj.appy()
|
||||
try:
|
||||
return self.method(obj)
|
||||
except Exception, e:
|
||||
obj.log(Traceback.get(), type='error')
|
||||
return str(e)
|
||||
return self.callMethod(obj, self.method, raiseOnError=False)
|
||||
|
||||
def getFormattedValue(self, obj, value):
|
||||
if not isinstance(value, basestring): return str(value)
|
||||
|
@ -2106,6 +2138,11 @@ class Config:
|
|||
# If you don't need the portlet that appy.gen has generated for your
|
||||
# application, set the following parameter to False.
|
||||
self.showPortlet = True
|
||||
# Number of translations for every page on a Translation object
|
||||
self.translationsPerPage = 30
|
||||
# Language that will be used as a basis for translating to other
|
||||
# languages.
|
||||
self.sourceLanguage = 'en'
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Special field "type" is mandatory for every class. If one class does not
|
||||
|
|
|
@ -70,12 +70,22 @@ class ClassDescriptor(Descriptor):
|
|||
return res
|
||||
|
||||
def getPhases(self):
|
||||
'''Gets the phases defined on fields of this class.'''
|
||||
res = []
|
||||
'''Lazy-gets the phases defined on fields of this class.'''
|
||||
if not hasattr(self, 'phases') or (self.phases == None):
|
||||
self.phases = []
|
||||
for fieldName, appyType, klass in self.getOrderedAppyAttributes():
|
||||
if appyType.page.phase not in res:
|
||||
res.append(appyType.page.phase)
|
||||
return res
|
||||
if appyType.page.phase in self.phases: continue
|
||||
self.phases.append(appyType.page.phase)
|
||||
return self.phases
|
||||
|
||||
def getPages(self):
|
||||
'''Lazy-gets the page names defined on fields of this class.'''
|
||||
if not hasattr(self, 'pages') or (self.pages == None):
|
||||
self.pages = []
|
||||
for fieldName, appyType, klass in self.getOrderedAppyAttributes():
|
||||
if appyType.page.name in self.pages: continue
|
||||
self.pages.append(appyType.page.name)
|
||||
return self.pages
|
||||
|
||||
class WorkflowDescriptor(Descriptor):
|
||||
'''This class gives information about an Appy workflow.'''
|
||||
|
|
|
@ -146,8 +146,6 @@ class FieldDescriptor:
|
|||
if self.appyType.indexed and \
|
||||
(self.fieldName not in ('title', 'description')):
|
||||
self.classDescr.addIndexMethod(self)
|
||||
# - searchable ? TODO
|
||||
#if self.appyType.searchable: self.fieldParams['searchable'] = True
|
||||
# i18n labels
|
||||
i18nPrefix = "%s_%s" % (self.classDescr.name, self.fieldName)
|
||||
# Create labels for generating them in i18n files.
|
||||
|
@ -160,17 +158,20 @@ class FieldDescriptor:
|
|||
if self.appyType.hasHelp:
|
||||
helpId = i18nPrefix + '_help'
|
||||
messages.append(self.produceMessage(helpId, isLabel=False))
|
||||
# Create i18n messages linked to pages and phases
|
||||
messages = self.generator.labels
|
||||
pageMsgId = '%s_page_%s' % (self.classDescr.name,
|
||||
self.appyType.page.name)
|
||||
phaseMsgId = '%s_phase_%s' % (self.classDescr.name,
|
||||
self.appyType.page.phase)
|
||||
pagePoMsg = PoMessage(pageMsgId, '',
|
||||
produceNiceMessage(self.appyType.page.name))
|
||||
phasePoMsg = PoMessage(phaseMsgId, '',
|
||||
produceNiceMessage(self.appyType.page.phase))
|
||||
for poMsg in (pagePoMsg, phasePoMsg):
|
||||
# Create i18n messages linked to pages and phases, only if there is more
|
||||
# than one page/phase for the class.
|
||||
ppMsgs = []
|
||||
if len(self.classDescr.getPhases()) > 1:
|
||||
# Create the message for the name of the phase
|
||||
phaseName = self.appyType.page.phase
|
||||
msgId = '%s_phase_%s' % (self.classDescr.name, phaseName)
|
||||
ppMsgs.append(PoMessage(msgId, '', produceNiceMessage(phaseName)))
|
||||
if len(self.classDescr.getPages()) > 1:
|
||||
# Create the message for the name of the page
|
||||
pageName = self.appyType.page.name
|
||||
msgId = '%s_page_%s' % (self.classDescr.name, pageName)
|
||||
ppMsgs.append(PoMessage(msgId, '', produceNiceMessage(pageName)))
|
||||
for poMsg in ppMsgs:
|
||||
if poMsg not in messages:
|
||||
messages.append(poMsg)
|
||||
self.classDescr.labelsToPropagate.append(poMsg)
|
||||
|
@ -237,6 +238,9 @@ class ClassDescriptor(appy.gen.descriptors.ClassDescriptor):
|
|||
self.name = getClassName(self.klass, generator.applicationName)
|
||||
self.predefined = False
|
||||
self.customized = False
|
||||
# Phase and page names will be calculated later, when first required.
|
||||
self.phases = None
|
||||
self.pages = None
|
||||
|
||||
def getParents(self, allClasses):
|
||||
parentWrapper = 'AbstractWrapper'
|
||||
|
@ -365,11 +369,16 @@ class ClassDescriptor(appy.gen.descriptors.ClassDescriptor):
|
|||
'self)\n' % n
|
||||
self.methods = m
|
||||
|
||||
def addField(self, fieldName, fieldType):
|
||||
'''Adds a new field to the Tool.'''
|
||||
exec "self.modelClass.%s = fieldType" % fieldName
|
||||
self.modelClass._appy_attributes.append(fieldName)
|
||||
self.orderedAttributes.append(fieldName)
|
||||
|
||||
class ToolClassDescriptor(ClassDescriptor):
|
||||
'''Represents the POD-specific fields that must be added to the tool.'''
|
||||
def __init__(self, klass, generator):
|
||||
ClassDescriptor.__init__(self,klass,klass._appy_attributes[:],generator)
|
||||
self.attributesByClass = klass._appy_classes
|
||||
self.modelClass = self.klass
|
||||
self.predefined = True
|
||||
self.customized = False
|
||||
|
@ -393,13 +402,6 @@ class ToolClassDescriptor(ClassDescriptor):
|
|||
def generateSchema(self):
|
||||
ClassDescriptor.generateSchema(self, configClass=True)
|
||||
|
||||
def addField(self, fieldName, fieldType, classDescr):
|
||||
'''Adds a new field to the Tool.'''
|
||||
exec "self.modelClass.%s = fieldType" % fieldName
|
||||
self.modelClass._appy_attributes.append(fieldName)
|
||||
self.orderedAttributes.append(fieldName)
|
||||
self.modelClass._appy_classes[fieldName] = classDescr.name
|
||||
|
||||
def addOptionalField(self, fieldDescr):
|
||||
className = fieldDescr.classDescr.name
|
||||
fieldName = 'optionalFieldsFor%s' % className
|
||||
|
@ -407,7 +409,7 @@ class ToolClassDescriptor(ClassDescriptor):
|
|||
if not fieldType:
|
||||
fieldType = String(multiplicity=(0,None))
|
||||
fieldType.validator = []
|
||||
self.addField(fieldName, fieldType, fieldDescr.classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
fieldType.validator.append(fieldDescr.fieldName)
|
||||
fieldType.page.name = 'data'
|
||||
fieldType.group = Group(fieldDescr.classDescr.klass.__name__)
|
||||
|
@ -416,7 +418,7 @@ class ToolClassDescriptor(ClassDescriptor):
|
|||
className = fieldDescr.classDescr.name
|
||||
fieldName = 'defaultValueFor%s_%s' % (className, fieldDescr.fieldName)
|
||||
fieldType = fieldDescr.appyType.clone()
|
||||
self.addField(fieldName, fieldType, fieldDescr.classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
fieldType.page.name = 'data'
|
||||
fieldType.group = Group(fieldDescr.classDescr.klass.__name__)
|
||||
|
||||
|
@ -429,12 +431,12 @@ class ToolClassDescriptor(ClassDescriptor):
|
|||
# Add the field that will store the pod template.
|
||||
fieldName = 'podTemplateFor%s_%s' % (className, fieldDescr.fieldName)
|
||||
fieldType = File(**pg)
|
||||
self.addField(fieldName, fieldType, fieldDescr.classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
# Add the field that will store the output format(s)
|
||||
fieldName = 'formatsFor%s_%s' % (className, fieldDescr.fieldName)
|
||||
fieldType = String(validator=('odt', 'pdf', 'doc', 'rtf'),
|
||||
multiplicity=(1,None), default=('odt',), **pg)
|
||||
self.addField(fieldName, fieldType, fieldDescr.classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
|
||||
def addQueryResultColumns(self, classDescr):
|
||||
'''Adds, for class p_classDescr, the attribute in the tool that allows
|
||||
|
@ -444,7 +446,7 @@ class ToolClassDescriptor(ClassDescriptor):
|
|||
fieldType = String(multiplicity=(0,None), validator=Selection(
|
||||
'_appy_getAllFields*%s' % className), page='userInterface',
|
||||
group=classDescr.klass.__name__)
|
||||
self.addField(fieldName, fieldType, classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
|
||||
def addSearchRelatedFields(self, classDescr):
|
||||
'''Adds, for class p_classDescr, attributes related to the search
|
||||
|
@ -455,13 +457,13 @@ class ToolClassDescriptor(ClassDescriptor):
|
|||
fieldName = 'enableAdvancedSearchFor%s' % className
|
||||
fieldType = Boolean(default=True, page='userInterface',
|
||||
group=classDescr.klass.__name__)
|
||||
self.addField(fieldName, fieldType, classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
# Field that defines how many columns are shown on the custom search
|
||||
# screen.
|
||||
fieldName = 'numberOfSearchColumnsFor%s' % className
|
||||
fieldType = Integer(default=3, page='userInterface',
|
||||
group=classDescr.klass.__name__)
|
||||
self.addField(fieldName, fieldType, classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
# Field that allows to select, among all indexed fields, what fields
|
||||
# must really be used in the search screen.
|
||||
fieldName = 'searchFieldsFor%s' % className
|
||||
|
@ -470,7 +472,7 @@ class ToolClassDescriptor(ClassDescriptor):
|
|||
fieldType = String(multiplicity=(0,None), validator=Selection(
|
||||
'_appy_getSearchableFields*%s' % className), default=defaultValue,
|
||||
page='userInterface', group=classDescr.klass.__name__)
|
||||
self.addField(fieldName, fieldType, classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
|
||||
def addImportRelatedFields(self, classDescr):
|
||||
'''Adds, for class p_classDescr, attributes related to the import
|
||||
|
@ -481,7 +483,7 @@ class ToolClassDescriptor(ClassDescriptor):
|
|||
defValue = classDescr.getCreateMean('Import').path
|
||||
fieldType = String(page='data', multiplicity=(1,1), default=defValue,
|
||||
group=classDescr.klass.__name__)
|
||||
self.addField(fieldName, fieldType, classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
|
||||
def addWorkflowFields(self, classDescr):
|
||||
'''Adds, for a given p_classDescr, the workflow-related fields.'''
|
||||
|
@ -495,12 +497,12 @@ class ToolClassDescriptor(ClassDescriptor):
|
|||
fieldName = 'showWorkflowFor%s' % className
|
||||
fieldType = Boolean(default=defaultValue, page='userInterface',
|
||||
group=groupName)
|
||||
self.addField(fieldName, fieldType, classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
# Adds the boolean field for showing or not the field "enter comments".
|
||||
fieldName = 'showWorkflowCommentFieldFor%s' % className
|
||||
fieldType = Boolean(default=defaultValue, page='userInterface',
|
||||
group=groupName)
|
||||
self.addField(fieldName, fieldType, classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
# Adds the boolean field for showing all states in current state or not.
|
||||
# If this boolean is True but the current phase counts only one state,
|
||||
# we will not show the state at all: the fact of knowing in what phase
|
||||
|
@ -512,7 +514,7 @@ class ToolClassDescriptor(ClassDescriptor):
|
|||
fieldName = 'showAllStatesInPhaseFor%s' % className
|
||||
fieldType = Boolean(default=defaultValue, page='userInterface',
|
||||
group=groupName)
|
||||
self.addField(fieldName, fieldType, classDescr)
|
||||
self.addField(fieldName, fieldType)
|
||||
|
||||
class UserClassDescriptor(ClassDescriptor):
|
||||
'''Represents an Archetypes-compliant class that corresponds to the User
|
||||
|
@ -534,10 +536,61 @@ class UserClassDescriptor(ClassDescriptor):
|
|||
self.orderedAttributes += attributes
|
||||
self.klass = klass
|
||||
self.customized = True
|
||||
def isFolder(self, klass=None): return True
|
||||
def isFolder(self, klass=None): return False
|
||||
def generateSchema(self):
|
||||
ClassDescriptor.generateSchema(self, configClass=True)
|
||||
|
||||
class TranslationClassDescriptor(ClassDescriptor):
|
||||
'''Represents the set of translation ids for a gen-application.'''
|
||||
|
||||
def __init__(self, klass, generator):
|
||||
ClassDescriptor.__init__(self,klass,klass._appy_attributes[:],generator)
|
||||
self.modelClass = self.klass
|
||||
self.predefined = True
|
||||
self.customized = False
|
||||
|
||||
def getParents(self, allClasses=()): return ('Translation',)
|
||||
|
||||
def generateSchema(self):
|
||||
ClassDescriptor.generateSchema(self, configClass=True)
|
||||
|
||||
def addLabelField(self, messageId, page):
|
||||
'''Adds a Computed field that will display, in the source language, the
|
||||
content of the text to translate.'''
|
||||
field = Computed(method=self.modelClass.computeLabel, plainText=False,
|
||||
page=page, show=self.modelClass.showField, layouts='f')
|
||||
self.addField('%s_label' % messageId, field)
|
||||
|
||||
def addMessageField(self, messageId, page, i18nFiles):
|
||||
'''Adds a message field corresponding to p_messageId to the Translation
|
||||
class, on a given p_page. We need i18n files p_i18nFiles for
|
||||
fine-tuning the String type to generate for this field (one-line?
|
||||
several lines?...)'''
|
||||
params = {'page':page, 'layouts':'f', 'show':self.modelClass.showField}
|
||||
appName = self.generator.applicationName
|
||||
# Scan all messages corresponding to p_messageId from all translation
|
||||
# files. We will define field length from the longer found message
|
||||
# content.
|
||||
maxLine = 100 # We suppose a line is 100 characters long.
|
||||
width = 0
|
||||
height = 0
|
||||
for fileName, poFile in i18nFiles.iteritems():
|
||||
if not fileName.startswith('%s-' % appName): continue
|
||||
msgContent = i18nFiles[fileName].messagesDict[messageId].msg
|
||||
# Compute width
|
||||
width = max(width, len(msgContent))
|
||||
# Compute height (a "\n" counts for one line)
|
||||
mHeight = int(len(msgContent)/maxLine) + msgContent.count('<br/>')
|
||||
height = max(height, mHeight)
|
||||
if height < 1:
|
||||
# This is a one-line field.
|
||||
params['width'] = width
|
||||
else:
|
||||
# This is a multi-line field, or a very-long-single-lined field
|
||||
params['format'] = String.TEXT
|
||||
params['height'] = height
|
||||
self.addField(messageId, String(**params))
|
||||
|
||||
class WorkflowDescriptor(appy.gen.descriptors.WorkflowDescriptor):
|
||||
'''Represents a workflow.'''
|
||||
# How to map Appy permissions to Plone permissions ?
|
||||
|
|
|
@ -9,8 +9,9 @@ from appy.gen.po import PoMessage, PoFile, PoParser
|
|||
from appy.gen.generator import Generator as AbstractGenerator
|
||||
from appy.gen.utils import getClassName
|
||||
from descriptors import ClassDescriptor, WorkflowDescriptor, \
|
||||
ToolClassDescriptor, UserClassDescriptor
|
||||
from model import ModelClass, User, Tool
|
||||
ToolClassDescriptor, UserClassDescriptor, \
|
||||
TranslationClassDescriptor
|
||||
from model import ModelClass, User, Tool, Translation
|
||||
|
||||
# Common methods that need to be defined on every Archetype class --------------
|
||||
COMMON_METHODS = '''
|
||||
|
@ -32,16 +33,14 @@ class Generator(AbstractGenerator):
|
|||
# Set our own Descriptor classes
|
||||
self.descriptorClasses['class'] = ClassDescriptor
|
||||
self.descriptorClasses['workflow'] = WorkflowDescriptor
|
||||
# Create our own Tool and User instances
|
||||
# Create our own Tool, User and Translation instances
|
||||
self.tool = ToolClassDescriptor(Tool, self)
|
||||
self.user = UserClassDescriptor(User, self)
|
||||
self.translation = TranslationClassDescriptor(Translation, self)
|
||||
# i18n labels to generate
|
||||
self.labels = [] # i18n labels
|
||||
self.toolName = '%sTool' % self.applicationName
|
||||
self.toolInstanceName = 'portal_%s' % self.applicationName.lower()
|
||||
self.userName = '%sUser' % self.applicationName
|
||||
self.portletName = '%s_portlet' % self.applicationName.lower()
|
||||
self.queryName = '%s_query' % self.applicationName.lower()
|
||||
self.skinsFolder = 'skins/%s' % self.applicationName
|
||||
# The following dict, pre-filled in the abstract generator, contains a
|
||||
# series of replacements that need to be applied to file templates to
|
||||
|
@ -49,9 +48,7 @@ class Generator(AbstractGenerator):
|
|||
commonMethods = COMMON_METHODS % \
|
||||
(self.toolInstanceName, self.applicationName)
|
||||
self.repls.update(
|
||||
{'toolName': self.toolName, 'portletName': self.portletName,
|
||||
'queryName': self.queryName, 'userName': self.userName,
|
||||
'toolInstanceName': self.toolInstanceName,
|
||||
{'toolInstanceName': self.toolInstanceName,
|
||||
'commonMethods': commonMethods})
|
||||
self.referers = {}
|
||||
|
||||
|
@ -150,10 +147,8 @@ class Generator(AbstractGenerator):
|
|||
niceDefault=True))
|
||||
# Create basic files (config.py, Install.py, etc)
|
||||
self.generateTool()
|
||||
self.generateConfig()
|
||||
self.generateInit()
|
||||
self.generateWorkflows()
|
||||
self.generateWrappers()
|
||||
self.generateTests()
|
||||
if self.config.frontPage:
|
||||
self.generateFrontPage()
|
||||
|
@ -198,11 +193,22 @@ class Generator(AbstractGenerator):
|
|||
fullName = os.path.join(self.outputFolder, 'i18n/%s' % potFileName)
|
||||
potFile = PoFile(fullName)
|
||||
self.i18nFiles[potFileName] = potFile
|
||||
# We update the POT file with our list of automatically managed labels.
|
||||
removedLabels = potFile.update(self.labels, self.options.i18nClean,
|
||||
not self.options.i18nSort)
|
||||
if removedLabels:
|
||||
print 'Warning: %d messages were removed from translation ' \
|
||||
'files: %s' % (len(removedLabels), str(removedLabels))
|
||||
# Before generating the POT file, we still need to add one label for
|
||||
# every page for the Translation class. We've not done it yet because
|
||||
# the number of pages depends on the total number of labels in the POT
|
||||
# file.
|
||||
pageLabels = []
|
||||
nbOfPages = int(len(potFile.messages)/self.config.translationsPerPage)+1
|
||||
for i in range(nbOfPages):
|
||||
msgId = '%s_page_%d' % (self.translation.name, i+2)
|
||||
pageLabels.append(msg(msgId, '', 'Page %d' % (i+2)))
|
||||
potFile.update(pageLabels, keepExistingOrder=False)
|
||||
potFile.generate()
|
||||
# Generate i18n po files
|
||||
for language in self.config.languages:
|
||||
|
@ -219,10 +225,27 @@ class Generator(AbstractGenerator):
|
|||
poFile.update(potFile.messages, self.options.i18nClean,
|
||||
not self.options.i18nSort)
|
||||
poFile.generate()
|
||||
# Generate corresponding fields on the Translation class
|
||||
page = 'main'
|
||||
i = 0
|
||||
for message in potFile.messages:
|
||||
i += 1
|
||||
# A computed field is used for displaying the text to translate.
|
||||
self.translation.addLabelField(message.id, page)
|
||||
# A String field will hold the translation in itself.
|
||||
self.translation.addMessageField(message.id, page, self.i18nFiles)
|
||||
if (i % self.config.translationsPerPage) == 0:
|
||||
# A new page must be defined.
|
||||
if page == 'main':
|
||||
page = '2'
|
||||
else:
|
||||
page = str(int(page)+1)
|
||||
# Generate i18n po files for other potential files
|
||||
for poFile in self.i18nFiles.itervalues():
|
||||
if not poFile.generated:
|
||||
poFile.generate()
|
||||
self.generateWrappers()
|
||||
self.generateConfig()
|
||||
|
||||
def getAllUsedRoles(self, plone=None, local=None, grantable=None):
|
||||
'''Produces a list of all the roles used within all workflows and
|
||||
|
@ -283,10 +306,14 @@ class Generator(AbstractGenerator):
|
|||
|
||||
def generateConfig(self):
|
||||
repls = self.repls.copy()
|
||||
# Get some lists of classes
|
||||
classes = self.getClasses()
|
||||
classesWithCustom = self.getClasses(include='custom')
|
||||
classesButTool = self.getClasses(include='allButTool')
|
||||
classesAll = self.getClasses(include='all')
|
||||
# Compute imports
|
||||
imports = ['import %s' % self.applicationName]
|
||||
classDescrs = self.getClasses(include='custom')
|
||||
for classDescr in (classDescrs + self.workflows):
|
||||
for classDescr in (classesWithCustom + self.workflows):
|
||||
theImport = 'import %s' % classDescr.klass.__module__
|
||||
if theImport not in imports:
|
||||
imports.append(theImport)
|
||||
|
@ -296,31 +323,24 @@ class Generator(AbstractGenerator):
|
|||
['"%s"' % r for r in self.config.defaultCreators])
|
||||
# Compute list of add permissions
|
||||
addPermissions = ''
|
||||
for classDescr in self.getClasses(include='allButTool'):
|
||||
for classDescr in classesButTool:
|
||||
addPermissions += ' "%s":"%s: Add %s",\n' % (classDescr.name,
|
||||
self.applicationName, classDescr.name)
|
||||
repls['addPermissions'] = addPermissions
|
||||
# Compute root classes
|
||||
rootClasses = ''
|
||||
for classDescr in self.getClasses(include='allButTool'):
|
||||
if classDescr.isRoot():
|
||||
rootClasses += "'%s'," % classDescr.name
|
||||
repls['rootClasses'] = rootClasses
|
||||
repls['rootClasses'] = ','.join(["'%s'" % c.name \
|
||||
for c in classesButTool if c.isRoot()])
|
||||
# Compute list of class definitions
|
||||
appClasses = []
|
||||
for classDescr in self.classes:
|
||||
k = classDescr.klass
|
||||
appClasses.append('%s.%s' % (k.__module__, k.__name__))
|
||||
repls['appClasses'] = "[%s]" % ','.join(appClasses)
|
||||
repls['appClasses'] = ','.join(['%s.%s' % (c.klass.__module__, \
|
||||
c.klass.__name__) for c in classes])
|
||||
# Compute lists of class names
|
||||
allClassNames = '"%s",' % self.userName
|
||||
appClassNames = ','.join(['"%s"' % c.name for c in self.classes])
|
||||
allClassNames += appClassNames
|
||||
repls['allClassNames'] = allClassNames
|
||||
repls['appClassNames'] = appClassNames
|
||||
repls['appClassNames'] = ','.join(['"%s"' % c.name \
|
||||
for c in classes])
|
||||
repls['allClassNames'] = ','.join(['"%s"' % c.name \
|
||||
for c in classesButTool])
|
||||
# Compute classes whose instances must not be catalogued.
|
||||
catalogMap = ''
|
||||
blackClasses = [self.toolName]
|
||||
blackClasses = [self.tool.name]
|
||||
for blackClass in blackClasses:
|
||||
catalogMap += "catalogMap['%s'] = {}\n" % blackClass
|
||||
catalogMap += "catalogMap['%s']['black'] = " \
|
||||
|
@ -328,7 +348,7 @@ class Generator(AbstractGenerator):
|
|||
repls['catalogMap'] = catalogMap
|
||||
# Compute workflows
|
||||
workflows = ''
|
||||
for classDescr in self.getClasses(include='all'):
|
||||
for classDescr in classesAll:
|
||||
if hasattr(classDescr.klass, 'workflow'):
|
||||
wfName = WorkflowDescriptor.getWorkflowName(
|
||||
classDescr.klass.workflow)
|
||||
|
@ -357,7 +377,7 @@ class Generator(AbstractGenerator):
|
|||
# inherited included) for every Appy class.
|
||||
attributes = []
|
||||
attributesDict = []
|
||||
for classDescr in self.getClasses(include='all'):
|
||||
for classDescr in classesAll:
|
||||
titleFound = False
|
||||
attrs = []
|
||||
attrNames = []
|
||||
|
@ -402,8 +422,8 @@ class Generator(AbstractGenerator):
|
|||
repls['languages'] = ','.join('"%s"' % l for l in self.config.languages)
|
||||
repls['languageSelector'] = self.config.languageSelector
|
||||
repls['minimalistPlone'] = self.config.minimalistPlone
|
||||
|
||||
repls['appFrontPage'] = bool(self.config.frontPage)
|
||||
repls['sourceLanguage'] = self.config.sourceLanguage
|
||||
self.copyFile('config.py', repls)
|
||||
|
||||
def generateInit(self):
|
||||
|
@ -470,21 +490,24 @@ class Generator(AbstractGenerator):
|
|||
|
||||
def getClasses(self, include=None):
|
||||
'''Returns the descriptors for all the classes in the generated
|
||||
gen-application. If p_include is "all", it includes the descriptors
|
||||
for the config-related classes (tool, user, etc); if p_include is
|
||||
"allButTool", it includes the same descriptors, the tool excepted;
|
||||
if p_include is "custom", it includes descriptors for the
|
||||
config-related classes for which the user has created a sub-class.'''
|
||||
gen-application. If p_include is:
|
||||
* "all" it includes the descriptors for the config-related
|
||||
classes (tool, user, translation)
|
||||
* "allButTool" it includes the same descriptors, the tool excepted
|
||||
* "custom" it includes descriptors for the config-related classes
|
||||
for which the user has created a sub-class.'''
|
||||
if not include: return self.classes
|
||||
else:
|
||||
res = self.classes[:]
|
||||
configClasses = [self.tool, self.user]
|
||||
configClasses = [self.tool, self.user, self.translation]
|
||||
if include == 'all':
|
||||
res += configClasses
|
||||
elif include == 'allButTool':
|
||||
res += configClasses[1:]
|
||||
elif include == 'custom':
|
||||
res += [c for c in configClasses if c.customized]
|
||||
elif include == 'predefined':
|
||||
res = configClasses
|
||||
return res
|
||||
|
||||
def getClassesInOrder(self, allClasses):
|
||||
|
@ -569,8 +592,9 @@ class Generator(AbstractGenerator):
|
|||
repls = self.repls.copy()
|
||||
repls['imports'] = '\n'.join(imports)
|
||||
repls['wrappers'] = '\n'.join(wrappers)
|
||||
repls['toolBody'] = Tool._appy_getBody()
|
||||
repls['userBody'] = User._appy_getBody()
|
||||
for klass in self.getClasses(include='predefined'):
|
||||
modelClass = klass.modelClass
|
||||
repls['%s' % modelClass.__name__] = modelClass._appy_getBody()
|
||||
self.copyFile('appyWrappers.py', repls, destFolder='Extensions')
|
||||
|
||||
def generateTests(self):
|
||||
|
@ -608,24 +632,31 @@ class Generator(AbstractGenerator):
|
|||
Msg = PoMessage
|
||||
# Create Tool-related i18n-related messages
|
||||
self.labels += [
|
||||
Msg(self.toolName, '', Msg.CONFIG % self.applicationName),
|
||||
Msg('%s_edit_descr' % self.toolName, '', ' ')]
|
||||
Msg(self.tool.name, '', Msg.CONFIG % self.applicationName),
|
||||
Msg('%s_edit_descr' % self.tool.name, '', ' ')]
|
||||
|
||||
# Tune the Ref field between Tool and User
|
||||
Tool.users.klass = User
|
||||
if self.user.customized:
|
||||
Tool.users.klass = self.user.klass
|
||||
|
||||
# Generate the User class
|
||||
self.user.generateSchema()
|
||||
self.labels += [ Msg(self.userName, '', Msg.USER),
|
||||
Msg('%s_edit_descr' % self.userName, '', ' '),
|
||||
Msg('%s_plural' % self.userName, '',self.userName+'s')]
|
||||
# Generate the Tool-related classes (User, Translation)
|
||||
for klass in (self.user, self.translation):
|
||||
klassType = klass.name[len(self.applicationName):]
|
||||
klass.generateSchema()
|
||||
self.labels += [ Msg(klass.name, '', klassType),
|
||||
Msg('%s_edit_descr' % klass.name, '', ' '),
|
||||
Msg('%s_plural' % klass.name,'', klass.name+'s')]
|
||||
repls = self.repls.copy()
|
||||
repls['fields'] = self.user.schema
|
||||
repls['methods'] = self.user.methods
|
||||
repls['wrapperClass'] = '%s_Wrapper' % self.user.name
|
||||
self.copyFile('UserTemplate.py', repls,destName='%s.py' % self.userName)
|
||||
repls.update({'fields': klass.schema, 'methods': klass.methods,
|
||||
'genClassName': klass.name, 'imports': '','baseMixin':'BaseMixin',
|
||||
'baseSchema': 'BaseSchema', 'global_allow': 1,
|
||||
'parents': 'BaseMixin, BaseContent', 'static': '',
|
||||
'classDoc': 'User class for %s' % self.applicationName,
|
||||
'implements': "(getattr(BaseContent,'__implements__',()),)",
|
||||
'register': "registerType(%s, '%s')" % (klass.name,
|
||||
self.applicationName)})
|
||||
self.copyFile('Class.py', repls, destName='%s.py' % klass.name)
|
||||
|
||||
# Before generating the Tool class, finalize it with query result
|
||||
# columns, with fields to propagate, workflow-related fields.
|
||||
|
@ -634,7 +665,7 @@ class Generator(AbstractGenerator):
|
|||
for childDescr in classDescr.getChildren():
|
||||
childFieldName = fieldName % childDescr.name
|
||||
fieldType.group = childDescr.klass.__name__
|
||||
self.tool.addField(childFieldName, fieldType, childDescr)
|
||||
self.tool.addField(childFieldName, fieldType)
|
||||
if classDescr.isRoot():
|
||||
# We must be able to configure query results from the tool.
|
||||
self.tool.addQueryResultColumns(classDescr)
|
||||
|
@ -648,11 +679,22 @@ class Generator(AbstractGenerator):
|
|||
|
||||
# Generate the Tool class
|
||||
repls = self.repls.copy()
|
||||
repls['metaTypes'] = [c.name for c in self.classes]
|
||||
repls['fields'] = self.tool.schema
|
||||
repls['methods'] = self.tool.methods
|
||||
repls['wrapperClass'] = '%s_Wrapper' % self.tool.name
|
||||
self.copyFile('ToolTemplate.py', repls, destName='%s.py'% self.toolName)
|
||||
repls.update({'fields': self.tool.schema, 'methods': self.tool.methods,
|
||||
'genClassName': self.tool.name, 'imports':'', 'baseMixin':'ToolMixin',
|
||||
'baseSchema': 'OrderedBaseFolderSchema', 'global_allow': 0,
|
||||
'parents': 'ToolMixin, UniqueObject, OrderedBaseFolder',
|
||||
'classDoc': 'Tool class for %s' % self.applicationName,
|
||||
'implements': "(getattr(UniqueObject,'__implements__',()),) + " \
|
||||
"(getattr(OrderedBaseFolder,'__implements__',()),)",
|
||||
'register': "registerType(%s, '%s')" % (self.tool.name,
|
||||
self.applicationName),
|
||||
'static': "left_slots = ['here/portlet_prefs/macros/portlet']\n " \
|
||||
"right_slots = []\n " \
|
||||
"def __init__(self, id=None):\n " \
|
||||
" OrderedBaseFolder.__init__(self, '%s')\n " \
|
||||
" self.setTitle('%s')\n" % (self.toolInstanceName,
|
||||
self.applicationName)})
|
||||
self.copyFile('Class.py', repls, destName='%s.py' % self.tool.name)
|
||||
|
||||
def generateClass(self, classDescr):
|
||||
'''Is called each time an Appy class is found in the application, for
|
||||
|
@ -694,11 +736,11 @@ class Generator(AbstractGenerator):
|
|||
classDescr.generateSchema()
|
||||
repls.update({
|
||||
'imports': '\n'.join(imports), 'parents': parents,
|
||||
'className': classDescr.klass.__name__,
|
||||
'genClassName': classDescr.name,
|
||||
'className': classDescr.klass.__name__, 'global_allow': 1,
|
||||
'genClassName': classDescr.name, 'baseMixin':'BaseMixin',
|
||||
'classDoc': classDoc, 'applicationName': self.applicationName,
|
||||
'fields': classDescr.schema, 'methods': classDescr.methods,
|
||||
'implements': implements, 'baseSchema': baseSchema,
|
||||
'implements': implements, 'baseSchema': baseSchema, 'static': '',
|
||||
'register': register, 'toolInstanceName': self.toolInstanceName})
|
||||
fileName = '%s.py' % classDescr.name
|
||||
# Create i18n labels (class name, description and plural form)
|
||||
|
@ -726,7 +768,7 @@ class Generator(AbstractGenerator):
|
|||
if poMsg not in self.labels:
|
||||
self.labels.append(poMsg)
|
||||
# Generate the resulting Archetypes class and schema.
|
||||
self.copyFile('ArchetypesTemplate.py', repls, destName=fileName)
|
||||
self.copyFile('Class.py', repls, destName=fileName)
|
||||
|
||||
def generateWorkflow(self, wfDescr):
|
||||
'''This method does not generate the workflow definition, which is done
|
||||
|
|
|
@ -7,8 +7,10 @@ from StringIO import StringIO
|
|||
from sets import Set
|
||||
import appy
|
||||
from appy.gen import Type, Ref
|
||||
from appy.gen.po import PoParser
|
||||
from appy.gen.utils import produceNiceMessage
|
||||
from appy.gen.plone25.utils import updateRolesForPermission
|
||||
from appy.shared.data import languages
|
||||
|
||||
class ZCTextIndexInfo:
|
||||
'''Silly class used for storing information about a ZCTextIndex.'''
|
||||
|
@ -313,6 +315,38 @@ class PloneInstaller:
|
|||
'%sID' % self.toolName, 'site_icon.gif', # Icon in control_panel
|
||||
self.productName, None)
|
||||
|
||||
def installTranslations(self):
|
||||
'''Creates or updates the translation objects within the tool.'''
|
||||
translations = [t.o.id for t in self.appyTool.translations]
|
||||
# We browse the languages supported by this application and check
|
||||
# whether we need to create the corresponding Translation objects.
|
||||
for language in self.languages:
|
||||
if language in translations: continue
|
||||
# We will create, in the tool, the translation object for this
|
||||
# language. Determine first its title.
|
||||
langId, langEn, langNat = languages.get(language)
|
||||
if langEn != langNat:
|
||||
title = '%s (%s)' % (langEn, langNat)
|
||||
else:
|
||||
title = langEn
|
||||
self.appyTool.create('translations', id=language, title=title)
|
||||
self.appyTool.log('Translation object created for "%s".' % language)
|
||||
# Now, we synchronise every Translation object with the corresponding
|
||||
# "po" file on disk.
|
||||
appFolder = self.config.diskFolder
|
||||
appName = self.config.PROJECTNAME
|
||||
dn = os.path.dirname
|
||||
jn = os.path.join
|
||||
i18nFolder = jn(jn(jn(dn(dn(dn(appFolder))),'Products'),appName),'i18n')
|
||||
for translation in self.appyTool.translations:
|
||||
# Get the "po" file
|
||||
poName = '%s-%s.po' % (appName, translation.id)
|
||||
poFile = PoParser(jn(i18nFolder, poName)).parse()
|
||||
for message in poFile.messages:
|
||||
setattr(translation, message.id, message.getMessage())
|
||||
self.appyTool.log('Translation "%s" updated from "%s".' % \
|
||||
(translation.id, poName))
|
||||
|
||||
def installRolesAndGroups(self):
|
||||
'''Registers roles used by workflows and classes defined in this
|
||||
application if they are not registered yet. Creates the corresponding
|
||||
|
@ -459,6 +493,7 @@ class PloneInstaller:
|
|||
self.installRootFolder()
|
||||
self.installTypes()
|
||||
self.installTool()
|
||||
self.installTranslations()
|
||||
self.installRolesAndGroups()
|
||||
self.installWorkflows()
|
||||
self.installStyleSheet()
|
||||
|
|
|
@ -109,8 +109,8 @@ class ToolMixin(BaseMixin):
|
|||
fileName = appyTool.normalize(fileName)
|
||||
response = obj.REQUEST.RESPONSE
|
||||
response.setHeader('Content-Type', mimeTypes[format])
|
||||
response.setHeader('Content-Disposition', 'inline;filename="%s.%s"'\
|
||||
% (fileName, format))
|
||||
response.setHeader('Content-Disposition', 'inline;filename="%s.%s"' % \
|
||||
(fileName, format))
|
||||
f.close()
|
||||
# Execute the related action if relevant
|
||||
if doAction and podInfo['action']:
|
||||
|
@ -710,7 +710,7 @@ class ToolMixin(BaseMixin):
|
|||
fieldName, pageName = d2.split(':')
|
||||
sourceObj = self.uid_catalog(UID=d1)[0].getObject()
|
||||
label = '%s_%s' % (sourceObj.meta_type, fieldName)
|
||||
res['backText'] = u'%s : %s' % (sourceObj.Title().decode('utf-8'),
|
||||
res['backText'] = '%s : %s' % (sourceObj.Title(),
|
||||
self.translate(label))
|
||||
newNav = '%s.%s.%s.%%d.%s' % (t, d1, d2, totalNumber)
|
||||
# Among, first, previous, next and last, which one do I need?
|
||||
|
|
|
@ -384,8 +384,11 @@ class BaseMixin:
|
|||
refType = refObject.o.getAppyType(fieldName)
|
||||
value = getattr(refObject, fieldName)
|
||||
value = refType.getFormattedValue(refObject.o, value)
|
||||
if (refType.type == 'String') and (refType.format == 2):
|
||||
if refType.type == 'String':
|
||||
if refType.format == 2:
|
||||
value = self.xhtmlToText.sub(' ', value)
|
||||
elif type(value) in sequenceTypes:
|
||||
value = ', '.join(value)
|
||||
prefix = ''
|
||||
if res:
|
||||
prefix = ' | '
|
||||
|
@ -816,12 +819,13 @@ class BaseMixin:
|
|||
self.plone_utils.addPortalMessage(msg)
|
||||
return self.goto(self.getUrl(rq['HTTP_REFERER']))
|
||||
elif resultType == 'file':
|
||||
# msg does not contain a message, but a complete file to show as is.
|
||||
# (or, if your prefer, the message must be shown directly to the
|
||||
# user, not encapsulated in a Plone page).
|
||||
res = self.getProductConfig().File(msg.name, msg.name, msg,
|
||||
content_type=mimetypes.guess_type(msg.name)[0])
|
||||
return res.index_html(rq, rq.RESPONSE)
|
||||
# msg does not contain a message, but a file instance.
|
||||
response = self.REQUEST.RESPONSE
|
||||
response.setHeader('Content-Type',mimetypes.guess_type(msg.name)[0])
|
||||
response.setHeader('Content-Disposition', 'inline;filename="%s"' %\
|
||||
msg.name)
|
||||
response.write(msg.read())
|
||||
msg.close()
|
||||
elif resultType == 'redirect':
|
||||
# msg does not contain a message, but the URL where to redirect
|
||||
# the user.
|
||||
|
@ -1045,13 +1049,27 @@ class BaseMixin:
|
|||
'''Translates a given p_label into p_domain with p_mapping.'''
|
||||
cfg = self.getProductConfig()
|
||||
if not domain: domain = cfg.PROJECTNAME
|
||||
if domain != cfg.PROJECTNAME:
|
||||
# We need to translate something that is in a standard Zope catalog
|
||||
try:
|
||||
res = self.Control_Panel.TranslationService.utranslate(
|
||||
domain, label, mapping, self, default=default,
|
||||
target_language=language)
|
||||
except AttributeError:
|
||||
# When run in test mode, Zope does not create the TranslationService
|
||||
# When run in test mode, Zope does not create the
|
||||
# TranslationService
|
||||
res = label
|
||||
else:
|
||||
# We will get the translation from a Translation object.
|
||||
# In what language must we get the translation?
|
||||
if not language: language = self.REQUEST['LANGUAGE']
|
||||
tool = self.getTool()
|
||||
translation = getattr(self.getTool(), language).appy()
|
||||
res = getattr(translation, label, '')
|
||||
# Perform replacements if needed
|
||||
for name, repl in mapping.iteritems():
|
||||
res = res.replace('${%s}' % name, repl)
|
||||
# At present, there is no fallback machanism.
|
||||
return res
|
||||
|
||||
def getPageLayout(self, layoutType):
|
||||
|
|
|
@ -21,23 +21,26 @@ class ModelClass:
|
|||
# must not be given in the constructor (they are computed attributes).
|
||||
_appy_notinit = ('id', 'type', 'pythonType', 'slaves', 'isSelect',
|
||||
'hasLabel', 'hasDescr', 'hasHelp', 'master_css',
|
||||
'layouts', 'required', 'filterable', 'validable', 'backd',
|
||||
'isBack', 'sync', 'pageName')
|
||||
'required', 'filterable', 'validable', 'backd', 'isBack',
|
||||
'sync', 'pageName')
|
||||
|
||||
@classmethod
|
||||
def _appy_getTypeBody(klass, appyType):
|
||||
'''This method returns the code declaration for p_appyType.'''
|
||||
typeArgs = ''
|
||||
for attrName, attrValue in appyType.__dict__.iteritems():
|
||||
if attrName in ModelClass._appy_notinit:
|
||||
continue
|
||||
if isinstance(attrValue, basestring):
|
||||
if attrName in ModelClass._appy_notinit: continue
|
||||
if attrName == 'layouts':
|
||||
if klass.__name__ == 'Tool': continue
|
||||
# For Tool attributes we do not copy layout info. Indeed, most
|
||||
# fields added to the Tool are config-related attributes whose
|
||||
# layouts must be standard.
|
||||
attrValue = appyType.getInputLayouts()
|
||||
elif isinstance(attrValue, basestring):
|
||||
attrValue = '"%s"' % attrValue
|
||||
elif isinstance(attrValue, Ref):
|
||||
if attrValue.isBack:
|
||||
if not attrValue.isBack: continue
|
||||
attrValue = klass._appy_getTypeBody(attrValue)
|
||||
else:
|
||||
continue
|
||||
elif type(attrValue) == type(ModelClass):
|
||||
moduleName = attrValue.__module__
|
||||
if moduleName.startswith('appy.gen'):
|
||||
|
@ -49,8 +52,8 @@ class ModelClass:
|
|||
elif isinstance(attrValue, Group):
|
||||
attrValue = 'Group("%s")' % attrValue.name
|
||||
elif isinstance(attrValue, Page):
|
||||
attrValue = 'Page("%s")' % attrValue.name
|
||||
elif type(attrValue) == types.FunctionType:
|
||||
attrValue = 'pages["%s"]' % attrValue.name
|
||||
elif callable(attrValue):
|
||||
attrValue = '%sWrapper.%s'% (klass.__name__, attrValue.__name__)
|
||||
typeArgs += '%s=%s,' % (attrName, attrValue)
|
||||
return '%s(%s)' % (appyType.__class__.__name__, typeArgs)
|
||||
|
@ -59,7 +62,25 @@ class ModelClass:
|
|||
def _appy_getBody(klass):
|
||||
'''This method returns the code declaration of this class. We will dump
|
||||
this in appyWrappers.py in the resulting product.'''
|
||||
res = ''
|
||||
res = 'class %s(%sWrapper):\n' % (klass.__name__, klass.__name__)
|
||||
if klass.__name__ == 'Tool':
|
||||
res += ' folder=True\n'
|
||||
# First, scan all attributes, determine all used pages and create a
|
||||
# dict with it. It will prevent us from creating a new Page instance
|
||||
# for every field.
|
||||
pages = {}
|
||||
for attrName in klass._appy_attributes:
|
||||
exec 'appyType = klass.%s' % attrName
|
||||
if appyType.page.name not in pages:
|
||||
pages[appyType.page.name] = appyType.page
|
||||
res += ' pages = {'
|
||||
for page in pages.itervalues():
|
||||
# Determine page show
|
||||
pageShow = page.show
|
||||
if isinstance(pageShow, basestring): pageShow='"%s"' % pageShow
|
||||
res += '"%s":Page("%s", show=%s),'% (page.name, page.name, pageShow)
|
||||
res += '}\n'
|
||||
# Secondly, dump every attribute
|
||||
for attrName in klass._appy_attributes:
|
||||
exec 'appyType = klass.%s' % attrName
|
||||
res += ' %s=%s\n' % (attrName, klass._appy_getTypeBody(appyType))
|
||||
|
@ -86,25 +107,38 @@ class User(ModelClass):
|
|||
gm['multiplicity'] = (0, None)
|
||||
roles = String(validator=Selection('getGrantableRoles'), indexed=True, **gm)
|
||||
|
||||
# The Tool class ---------------------------------------------------------------
|
||||
# The Translation class --------------------------------------------------------
|
||||
class Translation(ModelClass):
|
||||
_appy_attributes = ['po', 'title']
|
||||
# All methods defined below are fake. Real versions are in the wrapper.
|
||||
def getPoFile(self): pass
|
||||
po = Action(action=getPoFile, page=Page('actions', show='view'),
|
||||
result='file')
|
||||
title = String(show=False, indexed=True)
|
||||
def computeLabel(self): pass
|
||||
def showField(self, name): pass
|
||||
|
||||
# The Tool class ---------------------------------------------------------------
|
||||
# Here are the prefixes of the fields generated on the Tool.
|
||||
toolFieldPrefixes = ('defaultValue', 'podTemplate', 'formats', 'resultColumns',
|
||||
'enableAdvancedSearch', 'numberOfSearchColumns',
|
||||
'searchFields', 'optionalFields', 'showWorkflow',
|
||||
'showWorkflowCommentField', 'showAllStatesInPhase')
|
||||
defaultToolFields = ('users', 'enableNotifications', 'unoEnabledPython',
|
||||
'openOfficePort', 'numberOfResultsPerPage',
|
||||
'listBoxesMaximumWidth')
|
||||
defaultToolFields = ('users', 'translations', 'enableNotifications',
|
||||
'unoEnabledPython', 'openOfficePort',
|
||||
'numberOfResultsPerPage', 'listBoxesMaximumWidth')
|
||||
|
||||
class Tool(ModelClass):
|
||||
# The following dict allows us to remember the original classes related to
|
||||
# the attributes we will add due to params in user attributes.
|
||||
_appy_classes = {} # ~{s_attributeName: s_className}~
|
||||
# In a ModelClass we need to declare attributes in the following list.
|
||||
_appy_attributes = list(defaultToolFields)
|
||||
|
||||
# Tool attributes
|
||||
def validPythonWithUno(self, value): pass # Real method in the wrapper
|
||||
unoEnabledPython = String(group="connectionToOpenOffice",
|
||||
validator=validPythonWithUno)
|
||||
openOfficePort = Integer(default=2002, group="connectionToOpenOffice")
|
||||
numberOfResultsPerPage = Integer(default=30)
|
||||
listBoxesMaximumWidth = Integer(default=100)
|
||||
# First arg of Ref field below is None because we don't know yet if it will
|
||||
# link to the predefined User class or a custom class defined in the
|
||||
# application.
|
||||
|
@ -112,13 +146,10 @@ class Tool(ModelClass):
|
|||
back=Ref(attribute='toTool'), page='users', queryable=True,
|
||||
queryFields=('login',), showHeaders=True,
|
||||
shownInfo=('login', 'title', 'roles'))
|
||||
translations = Ref(Translation, multiplicity=(0,None), add=False,link=False,
|
||||
back=Ref(attribute='trToTool', show=False), show='view',
|
||||
page=Page('translations', show='view'))
|
||||
enableNotifications = Boolean(default=True, page='notifications')
|
||||
def validPythonWithUno(self, value): pass # Real method in the wrapper
|
||||
unoEnabledPython = String(group="connectionToOpenOffice",
|
||||
validator=validPythonWithUno)
|
||||
openOfficePort = Integer(default=2002, group="connectionToOpenOffice")
|
||||
numberOfResultsPerPage = Integer(default=30)
|
||||
listBoxesMaximumWidth = Integer(default=100)
|
||||
|
||||
@classmethod
|
||||
def _appy_clean(klass):
|
||||
|
@ -130,5 +161,4 @@ class Tool(ModelClass):
|
|||
for k in toClean:
|
||||
exec 'del klass.%s' % k
|
||||
klass._appy_attributes = list(defaultToolFields)
|
||||
klass._appy_classes = {}
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 415 B |
|
@ -3,8 +3,10 @@ from AccessControl import ClassSecurityInfo
|
|||
from DateTime import DateTime
|
||||
from Products.Archetypes.atapi import *
|
||||
import Products.<!applicationName!>.config
|
||||
from Extensions.appyWrappers import <!genClassName!>_Wrapper
|
||||
from Products.CMFCore.utils import UniqueObject
|
||||
from appy.gen.plone25.mixins import BaseMixin
|
||||
from appy.gen.plone25.mixins.ToolMixin import ToolMixin
|
||||
from Extensions.appyWrappers import <!genClassName!>_Wrapper
|
||||
<!imports!>
|
||||
|
||||
schema = Schema((<!fields!>
|
||||
|
@ -18,19 +20,20 @@ class <!genClassName!>(<!parents!>):
|
|||
archetype_name = '<!genClassName!>'
|
||||
meta_type = '<!genClassName!>'
|
||||
portal_type = '<!genClassName!>'
|
||||
allowed_content_types = []
|
||||
allowed_content_types = ()
|
||||
filter_content_types = 0
|
||||
global_allow = 1
|
||||
global_allow = <!global_allow!>
|
||||
immediate_view = 'skyn/view'
|
||||
default_view = 'skyn/view'
|
||||
suppl_views = ()
|
||||
typeDescription = '<!genClassName!>'
|
||||
typeDescMsgId = '<!genClassName!>_edit_descr'
|
||||
i18nDomain = '<!applicationName!>'
|
||||
schema = fullSchema
|
||||
wrapperClass = <!genClassName!>_Wrapper
|
||||
for elem in dir(BaseMixin):
|
||||
schema = fullSchema
|
||||
for elem in dir(<!baseMixin!>):
|
||||
if not elem.startswith('__'): security.declarePublic(elem)
|
||||
<!static!>
|
||||
<!commonMethods!>
|
||||
<!methods!>
|
||||
<!register!>
|
|
@ -266,6 +266,13 @@ div.appyPopup {
|
|||
border: 1px solid gray;
|
||||
}
|
||||
|
||||
.translationLabel {
|
||||
background-color: #EAEAEA;
|
||||
border-bottom: 1px dashed grey;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* Uncomment this if you want to hide breadcrumbs */
|
||||
/*
|
||||
#portal-breadcrumbs {
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
<!codeHeader!>
|
||||
from AccessControl import ClassSecurityInfo
|
||||
from DateTime import DateTime
|
||||
from Products.Archetypes.atapi import *
|
||||
from Products.CMFCore.utils import UniqueObject
|
||||
import Products.<!applicationName!>.config
|
||||
from appy.gen.plone25.mixins.ToolMixin import ToolMixin
|
||||
from Extensions.appyWrappers import AbstractWrapper, <!wrapperClass!>
|
||||
|
||||
schema = Schema((<!fields!>
|
||||
),)
|
||||
fullSchema = OrderedBaseFolderSchema.copy() + schema.copy()
|
||||
|
||||
class <!toolName!>(ToolMixin, UniqueObject, OrderedBaseFolder):
|
||||
'''Tool for <!applicationName!>.'''
|
||||
security = ClassSecurityInfo()
|
||||
__implements__ = (getattr(UniqueObject,'__implements__',()),) + (getattr(OrderedBaseFolder,'__implements__',()),)
|
||||
|
||||
archetype_name = '<!toolName!>'
|
||||
meta_type = '<!toolName!>'
|
||||
portal_type = '<!toolName!>'
|
||||
allowed_content_types = ()
|
||||
filter_content_types = 0
|
||||
global_allow = 0
|
||||
#content_icon = '<!toolName!>.gif'
|
||||
immediate_view = 'skyn/view'
|
||||
default_view = 'skyn/view'
|
||||
suppl_views = ()
|
||||
typeDescription = "<!toolName!>"
|
||||
typeDescMsgId = '<!toolName!>_edit_descr'
|
||||
i18nDomain = '<!applicationName!>'
|
||||
allMetaTypes = <!metaTypes!>
|
||||
wrapperClass = <!wrapperClass!>
|
||||
schema = fullSchema
|
||||
schema["id"].widget.visible = False
|
||||
schema["title"].widget.visible = False
|
||||
# When browsing into the tool, the 'configure' portlet should be displayed.
|
||||
left_slots = ['here/portlet_prefs/macros/portlet']
|
||||
right_slots = []
|
||||
for elem in dir(ToolMixin):
|
||||
if not elem.startswith('__'): security.declarePublic(elem)
|
||||
|
||||
# Tool constructor has no id argument, the id is fixed.
|
||||
def __init__(self, id=None):
|
||||
OrderedBaseFolder.__init__(self, '<!toolInstanceName!>')
|
||||
self.setTitle('<!applicationName!>')
|
||||
<!commonMethods!>
|
||||
<!methods!>
|
||||
registerType(<!toolName!>, '<!applicationName!>')
|
|
@ -1,34 +0,0 @@
|
|||
<!codeHeader!>
|
||||
from AccessControl import ClassSecurityInfo
|
||||
from Products.Archetypes.atapi import *
|
||||
import Products.<!applicationName!>.config
|
||||
from appy.gen.plone25.mixins import BaseMixin
|
||||
from Extensions.appyWrappers import <!wrapperClass!>
|
||||
|
||||
schema = Schema((<!fields!>
|
||||
),)
|
||||
fullSchema = BaseSchema.copy() + schema.copy()
|
||||
|
||||
class <!applicationName!>User(BaseMixin, BaseContent):
|
||||
'''User mixin.'''
|
||||
security = ClassSecurityInfo()
|
||||
__implements__ = (getattr(BaseContent,'__implements__',()),)
|
||||
archetype_name = '<!applicationName!>User'
|
||||
meta_type = '<!applicationName!>User'
|
||||
portal_type = '<!applicationName!>User'
|
||||
allowed_content_types = []
|
||||
filter_content_types = 0
|
||||
global_allow = 1
|
||||
immediate_view = 'skyn/view'
|
||||
default_view = 'skyn/view'
|
||||
suppl_views = ()
|
||||
typeDescription = "<!applicationName!>User"
|
||||
typeDescMsgId = '<!applicationName!>User_edit_descr'
|
||||
i18nDomain = '<!applicationName!>'
|
||||
schema = fullSchema
|
||||
wrapperClass = <!wrapperClass!>
|
||||
for elem in dir(BaseMixin):
|
||||
if not elem.startswith('__'): security.declarePublic(elem)
|
||||
<!commonMethods!>
|
||||
<!methods!>
|
||||
registerType(<!applicationName!>User, '<!applicationName!>')
|
|
@ -3,16 +3,13 @@ from appy.gen import *
|
|||
from appy.gen.plone25.wrappers import AbstractWrapper
|
||||
from appy.gen.plone25.wrappers.ToolWrapper import ToolWrapper
|
||||
from appy.gen.plone25.wrappers.UserWrapper import UserWrapper
|
||||
from appy.gen.plone25.wrappers.TranslationWrapper import TranslationWrapper
|
||||
from Globals import InitializeClass
|
||||
from AccessControl import ClassSecurityInfo
|
||||
<!imports!>
|
||||
|
||||
class User(UserWrapper):
|
||||
'''This class represents a user.'''
|
||||
<!userBody!>
|
||||
class Tool(ToolWrapper):
|
||||
'''This class represents the tool for this application.'''
|
||||
folder=True
|
||||
<!toolBody!>
|
||||
<!User!>
|
||||
<!Translation!>
|
||||
<!Tool!>
|
||||
<!wrappers!>
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
|
@ -45,7 +45,7 @@ setDefaultRoles(DEFAULT_ADD_CONTENT_PERMISSION, tuple(defaultAddRoles))
|
|||
|
||||
# Applications classes, in various formats
|
||||
rootClasses = [<!rootClasses!>]
|
||||
appClasses = <!appClasses!>
|
||||
appClasses = [<!appClasses!>]
|
||||
appClassNames = [<!appClassNames!>]
|
||||
allClassNames = [<!allClassNames!>]
|
||||
# List of classes that must be hidden from the catalog
|
||||
|
@ -66,7 +66,7 @@ workflowInstances = {}
|
|||
# In the following dict, we store, for every Appy class, the ordered list of
|
||||
# appy types (included inherited ones).
|
||||
attributes = {<!attributes!>}
|
||||
# In the followinf dict, we store, for every Appy class, a dict of appy types
|
||||
# In the following dict, we store, for every Appy class, a dict of appy types
|
||||
# keyed by their names.
|
||||
attributesDict = {<!attributesDict!>}
|
||||
|
||||
|
@ -81,4 +81,5 @@ languages = [<!languages!>]
|
|||
languageSelector = <!languageSelector!>
|
||||
minimalistPlone = <!minimalistPlone!>
|
||||
appFrontPage = <!appFrontPage!>
|
||||
sourceLanguage = '<!sourceLanguage!>'
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
|
@ -105,4 +105,8 @@ class ToolWrapper(AbstractWrapper):
|
|||
res = '%sFor%s' % (attributeType, fullClassName)
|
||||
if attrName: res += '_%s' % attrName
|
||||
return res
|
||||
|
||||
def getAvailableLanguages(self):
|
||||
'''Returns the list of available languages for this application.'''
|
||||
return [(t.id, t.title) for t in self.translations]
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
76
gen/plone25/wrappers/TranslationWrapper.py
Normal file
76
gen/plone25/wrappers/TranslationWrapper.py
Normal file
|
@ -0,0 +1,76 @@
|
|||
# ------------------------------------------------------------------------------
|
||||
import os.path
|
||||
from appy.gen.plone25.wrappers import AbstractWrapper
|
||||
from appy.gen.po import PoFile, PoMessage
|
||||
from appy.shared.utils import getOsTempFolder
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
class TranslationWrapper(AbstractWrapper):
|
||||
def computeLabel(self, field):
|
||||
'''The label for a text to translate displays the text of the
|
||||
corresponding message in the source translation.'''
|
||||
tool = self.tool
|
||||
sourceLanguage = self.o.getProductConfig().sourceLanguage
|
||||
sourceTranslation = getattr(tool.o, sourceLanguage).appy()
|
||||
# p_field is the Computed field. We need to get the name of the
|
||||
# corresponding field holding the translation message.
|
||||
fieldName = field.name[:-6]
|
||||
# If we are showing the source translation, we do not repeat the message
|
||||
# in the label.
|
||||
if self.id == sourceLanguage:
|
||||
sourceMsg = ''
|
||||
else:
|
||||
sourceMsg = getattr(sourceTranslation,fieldName)
|
||||
# When editing the value, we don't want HTML code to be interpreted.
|
||||
# This way, the translator sees the HTML tags and can reproduce them in
|
||||
# the translation.
|
||||
if self.request['URL'].endswith('/skyn/edit'):
|
||||
sourceMsg = sourceMsg.replace('<','<').replace('>','>')
|
||||
sourceMsg = sourceMsg.replace('\n', '<br/>')
|
||||
return '<div class="translationLabel"><acronym title="%s">' \
|
||||
'<img src="help.png"/></acronym>%s</div>' % \
|
||||
(fieldName, sourceMsg)
|
||||
|
||||
def showField(self, field):
|
||||
'''We show a field (or its label) only if the corresponding source
|
||||
message is not empty.'''
|
||||
tool = self.tool
|
||||
if field.type == 'Computed': name = field.name[:-6]
|
||||
else: name = field.name
|
||||
# Get the source message
|
||||
sourceLanguage = self.o.getProductConfig().sourceLanguage
|
||||
sourceTranslation = getattr(tool.o, sourceLanguage).appy()
|
||||
sourceMsg = getattr(sourceTranslation, name)
|
||||
if field.isEmptyValue(sourceMsg): return False
|
||||
return True
|
||||
|
||||
poReplacements = ( ('\r\n', '<br/>'), ('\n', '<br/>'), ('"', '\\"') )
|
||||
def getPoFile(self):
|
||||
'''Computes and returns the PO file corresponding to this
|
||||
translation.'''
|
||||
tool = self.tool
|
||||
fileName = os.path.join(getOsTempFolder(),
|
||||
'%s-%s.po' % (tool.o.getAppName(), self.id))
|
||||
poFile = PoFile(fileName)
|
||||
for field in self.fields:
|
||||
if (field.name == 'title') or (field.type != 'String'): continue
|
||||
# Adds the PO message corresponding to this field
|
||||
msg = field.getValue(self.o) or ''
|
||||
for old, new in self.poReplacements:
|
||||
msg = msg.replace(old, new)
|
||||
poFile.addMessage(PoMessage(field.name, msg, ''))
|
||||
poFile.generate()
|
||||
return True, file(fileName)
|
||||
|
||||
def validate(self, new, errors):
|
||||
# Call a custom "validate" if any.
|
||||
self._callCustom('validate', new, errors)
|
||||
|
||||
def onEdit(self, created):
|
||||
# Call a custom "onEdit" if any.
|
||||
self._callCustom('onEdit', created)
|
||||
|
||||
def onDelete(self):
|
||||
# Call a custom "onDelete" if any.
|
||||
self._callCustom('onDelete')
|
||||
# ------------------------------------------------------------------------------
|
|
@ -4,17 +4,6 @@ from appy.gen.plone25.wrappers import AbstractWrapper
|
|||
# ------------------------------------------------------------------------------
|
||||
class UserWrapper(AbstractWrapper):
|
||||
|
||||
def _callCustom(self, methodName, *args, **kwargs):
|
||||
'''This wrapper implements some methods like "validate" and "onEdit".
|
||||
If the user has defined its own wrapper, its methods will not be
|
||||
called. So this method allows, from the methods here, to call the
|
||||
user versions.'''
|
||||
if len(self.__class__.__bases__) > 1:
|
||||
# There is a custom user class
|
||||
customUser = self.__class__.__bases__[-1]
|
||||
if customUser.__dict__.has_key(methodName):
|
||||
customUser.__dict__[methodName](self, *args, **kwargs)
|
||||
|
||||
def showLogin(self):
|
||||
'''When must we show the login field?'''
|
||||
if self.o.isTemporary(): return 'edit'
|
||||
|
|
|
@ -33,6 +33,17 @@ class AbstractWrapper:
|
|||
if other: return cmp(self.o, other.o)
|
||||
else: return 1
|
||||
|
||||
def _callCustom(self, methodName, *args, **kwargs):
|
||||
'''This wrapper implements some methods like "validate" and "onEdit".
|
||||
If the user has defined its own wrapper, its methods will not be
|
||||
called. So this method allows, from the methods here, to call the
|
||||
user versions.'''
|
||||
if len(self.__class__.__bases__) > 1:
|
||||
# There is a custom user class
|
||||
customUser = self.__class__.__bases__[-1]
|
||||
if customUser.__dict__.has_key(methodName):
|
||||
customUser.__dict__[methodName](self, *args, **kwargs)
|
||||
|
||||
def get_tool(self): return self.o.getTool().appy()
|
||||
tool = property(get_tool)
|
||||
|
||||
|
|
16
gen/po.py
16
gen/po.py
|
@ -42,7 +42,6 @@ class PoMessage:
|
|||
'comment every time a transition is ' \
|
||||
'triggered'
|
||||
MSG_showAllStatesInPhase = 'Show all states in phase'
|
||||
USER = 'User'
|
||||
POD_ASKACTION = 'Trigger related action'
|
||||
REF_NO = 'No object.'
|
||||
REF_ADD = 'Add a new one'
|
||||
|
@ -194,6 +193,10 @@ class PoMessage:
|
|||
newId = '%s_%s' % (newPrefix, self.id.split('_', 1)[1])
|
||||
return PoMessage(newId, self.msg, self.default, comments=self.comments)
|
||||
|
||||
def getMessage(self):
|
||||
'''Returns self.msg, but with some replacements.'''
|
||||
return self.msg.replace('<br/>', '\n').replace('\\"', '"')
|
||||
|
||||
class PoHeader:
|
||||
def __init__(self, name, value):
|
||||
self.name = name
|
||||
|
@ -236,8 +239,11 @@ class PoFile:
|
|||
self.generated = False # Is set to True during the generation process
|
||||
# when this file has been generated.
|
||||
|
||||
def addMessage(self, newMsg):
|
||||
def addMessage(self, newMsg, needsCopy=True):
|
||||
if needsCopy:
|
||||
res = copy.copy(newMsg)
|
||||
else:
|
||||
res = newMsg
|
||||
self.messages.append(res)
|
||||
self.messagesDict[res.id] = res
|
||||
return res
|
||||
|
@ -261,7 +267,9 @@ class PoFile:
|
|||
i = len(self.messages)-1
|
||||
while i >= 0:
|
||||
oldId = self.messages[i].id
|
||||
if not oldId.startswith('custom_') and (oldId not in newIds):
|
||||
if not oldId.startswith('custom_') and \
|
||||
not oldId.startswith('%sTranslation_page_'%self.domain) and \
|
||||
(oldId not in newIds):
|
||||
del self.messages[i]
|
||||
del self.messagesDict[oldId]
|
||||
removedIds.append(oldId)
|
||||
|
@ -343,7 +351,7 @@ class PoParser:
|
|||
|
||||
def parse(self):
|
||||
'''Parses all i18n messages in the file, stores it in
|
||||
self.res.messages and returns it also.'''
|
||||
self.res.messages and returns self.res.'''
|
||||
f = file(self.res.fileName)
|
||||
# Currently parsed values
|
||||
msgDefault = msgFuzzy = msgId = msgStr = None
|
||||
|
|
|
@ -197,6 +197,15 @@ class Languages:
|
|||
'''Is p_code a valid 2-digits language/country code?'''
|
||||
return code in self.languageCodes
|
||||
|
||||
def get(self, code):
|
||||
'''Returns information about the language whose code is p_code.'''
|
||||
try:
|
||||
iCode = self.languageCodes.index(code)
|
||||
return self.languageCodes[iCode], self.languageNames[iCode], \
|
||||
self.nativeNames[iCode]
|
||||
except ValueError:
|
||||
return None, None, None
|
||||
|
||||
def __repr__(self):
|
||||
i = -1
|
||||
res = ''
|
||||
|
|
Loading…
Reference in a new issue