source: trunk/Resources.py @ 1919

Revision 1919, 49.6 KB checked in by jukka, 12 years ago (diff)

Refactored groups to not use portal_groups. Things should be faster and users from weird sources shouldn't cause so much problems. Not much tested yet, but archetype update and quickinstaller reinstall works fine.

Line 
1#
2# This file is part of LeMill.
3#
4# LeMill 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# LeMill 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 LeMill; if not, write to the Free Software
16# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
17
18from Products.Archetypes.public import *
19from Products.Archetypes.atapi import DisplayList
20import Globals
21import os.path
22from Globals import InitializeClass
23from Products.CMFCore.utils import getToolByName
24from AccessControl import ClassSecurityInfo, Unauthorized
25from config import PROJECTNAME, MATERIAL_TYPES, LANGUAGE_INDEPENDENT_FIELDS, DEFAULT_ICONS, TYPE_ABBREVIATIONS
26from persistent.list import PersistentList
27from Products.LeMill import LeMillMessageFactory as _
28from Products.CMFPlone import PloneMessageFactory as PMF
29from permissions import ModerateContent, MODIFY_CONTENT, DELETE_CONTENT, MANAGE_PORTAL
30import datetime
31from DateTime import DateTime
32
33import time, re
34from itertools import count
35import difflib
36import traceback
37import sys
38from types import StringTypes
39
40# retains the first occurence of an object and drops the others
41def _remove_duplicates(d):      # XXX both _remove_duplicates and remove_duplicates does the same (to catch some bugs early)
42    d = list(d)
43    d.reverse()
44    creators_pos = dict([(c, -i) for i, c in enumerate(d)])
45    creators_pos_rev = dict([(v, k) for k, v in creators_pos.iteritems()])
46    return [v for k, v in sorted(creators_pos_rev.iteritems())]
47
48def remove_duplicates(d):
49    creator_positions = {}
50    for pos, creator in enumerate(d):
51        creator_positions.setdefault(creator, []).append(pos)
52
53    best_pos = sorted(((min(positions), creator) for creator, positions in creator_positions.iteritems()))
54    assert len(set((pos for pos, creator in best_pos))) == len(best_pos)
55    assert len(set((creator for pos, creator in best_pos))) == len(best_pos)
56
57    result = [creator for pos, creator in best_pos]
58    assert result == _remove_duplicates(d), "%s, %s" % (result, _remove_duplicates(d))
59    return result
60assert remove_duplicates((1, 2, 1, 7, 4, 2, 4, 6, 7, 8, 3, 2, 1)) == [1, 2, 7, 4, 6, 8, 3]
61
62class CommonMixIn:
63    """Superclass for all objects."""
64
65    def getDefaultIcon(self, meta_type='', obj=None):
66        """ general method for getting proper icon for object, used when only catalog-metadata is available """
67        # this combines folderish getDefaultIcon(for-this-type, object) and resource-specific object.getDefaultIcon()
68        # folderish behaviour is needed because members have these created resources-pages. 
69        if meta_type=='':
70            return  DEFAULT_ICONS[self.meta_type]
71        else:     
72            address=DEFAULT_ICONS[meta_type]
73            if address!='piece':
74                return address
75            else:
76                obj=obj.getObject()
77                return obj.getDefaultIcon()
78
79    def canDeleteOnCancel(self):
80        """ Check if object is just created and generally boring"""
81        obj_type=self.meta_type.lower()
82        obj_title=self.Title()
83        if obj_title:
84            obj_title.lower()
85        else:
86            obj_title=''
87        stupidname= self.getId().lower().startswith(obj_type)
88        notitle= not obj_title.startswith(obj_type)
89        timediff=DateTime()-DateTime(self.CreationDate())
90        if timediff<0.1 and stupidname and notitle:
91            return True
92        else:
93            return False
94
95    def getBodyText(self):
96        """ get body text, do nothing"""
97        field = self.getField('bodyText')
98        if not field:
99            return None
100        return field.get(self)
101
102    def getCookedBodyText(self):
103        """ get body text, run it through parsers"""
104        lt = getToolByName(self, 'lemill_tool')
105        bodytextfield = self.getField('bodyText')
106        if not bodytextfield:
107            return None
108
109        bodytext = bodytextfield.get(self)
110        if type(bodytext) in StringTypes:
111            return lt.shorten_link_names(lt.htmlify(bodytext))
112        else:
113            if bodytext and reduce(lambda x, y: x and y, (type(s) in StringTypes for s in bodytext), True):
114                return [lt.shorten_link_names(lt.htmlify(s)) for s in bodytext]
115            else:
116                return bodytext
117
118    def getRawBodyText(self):
119        """ get body text, do nothing"""
120        field = self.getField('bodyText')
121        if not field:
122            return None
123        return field.get(self)
124
125    def pretty_title_or_id(self):
126        """ add type abbreviation like [MP] before title """
127        if TYPE_ABBREVIATIONS.has_key(self.portal_type):
128            return '[%s] %s' % (TYPE_ABBREVIATIONS[self.portal_type], self.title_or_id())
129        else:
130            return '[%s] %s' % (self.portal_type, self.title_or_id())       
131
132    def recalculateScore(self):
133        """ fallback method if resource doesn't have this method implemented, do nothing """
134        pass
135
136    def getRelatedStories(self):
137        """ fallback method if resource doesn't have this method implemented, return empty array"""
138        return []
139
140InitializeClass(CommonMixIn)
141
142class CoverImageMixIn:
143    """Mix-in class for resources with cover images."""
144    security = ClassSecurityInfo()
145    def getCoverOrDefault(self):
146        wtool = getToolByName(self,'portal_workflow')
147        if self.getField('hasCoverImage').get(self)==True and \
148               wtool.getInfoFor(self,'review_state',None)!='draft':
149            return self.coverImage
150        return eval('default_%s.png' % self.meta_type.lower())
151       
152       
153    def getCoverImageURL(self, drafts=False):
154        """Returns the URL for the cover image. If drafts=True, also allow drafts to show cover image"""
155        wtool = getToolByName(self,'portal_workflow')
156        field = self.getField('hasCoverImage')
157        timestamp = time.time()
158        if field and field.get(self)==True and \
159               (wtool.getInfoFor(self,'review_state',None)!='draft' or drafts):
160            return self.absolute_url()+'/coverImage?newest='+str(timestamp)
161        return self.getDefaultIcon()
162
163    security.declareProtected(MODIFY_CONTENT,'setCoverImage')
164    def setCoverImage(self, value, **kwargs):
165        """ Normal mutator, but flags object to have a coverImage (hasCoverImage = True) """
166        cover=self.getField('coverImage')
167        cover.set(self,value,**kwargs)
168        has_cover=self.getField('hasCoverImage')
169        if value==None:
170            has_cover.set(self,False)
171        else:
172            has_cover.set(self,True)
173        self.reindexObject()
174
175    security.declareProtected(MODIFY_CONTENT,'delCoverImage')
176    def delCoverImage(self):
177        """ Reverse of setCoverImage """
178        cover=self.getField('coverImage')
179        cover.set(self, "DELETE_IMAGE")
180        orgCover = self.getField('originalCoverImage')
181        orgCover.set(self, "DELETE_IMAGE")
182        has_cover=self.getField('hasCoverImage')
183        has_cover.set(self,False)
184        self.reindexObject()
185
186InitializeClass(CoverImageMixIn)
187
188
189default_ir_regexp = re.compile(r'^[A-Z]?[a-z]+[-.]?[-.0-9]+[0-9]+$')
190def is_id_in_default_format(id):
191    return not not re.match(default_ir_regexp,id)
192
193class Resource(BaseContent,CommonMixIn,CoverImageMixIn):
194    """Superclass for all resources (pieces and learning resources)."""
195    global_allow = 1
196    _at_rename_after_creation = True
197
198    security = ClassSecurityInfo()
199
200    security.declarePrivate('manage_afterAdd')
201    def manage_afterAdd(self, item, container):
202        # Replaces the left side portlets with the content type's own action portlet.
203        try:
204            BaseContent.manage_afterAdd(self, item, container)
205        except TypeError:
206            pass # ZEXP import problem
207        if not hasattr(item.aq_base, 'left_slots'):
208            self._setProperty('left_slots', ['here/portlet_%s_actions/macros/portlet' % item.meta_type.lower(),], 'lines')
209
210    security.declarePrivate('at_post_create_script')
211    def at_post_create_script(self):
212        self.at_post_edit_script()
213
214    security.declarePrivate('at_post_edit_script')
215    def at_post_edit_script(self):
216        self.post_edit_rename()
217        self.post_edit_update_history()
218        self.post_edit_credit_author()
219        self.recalculateAuthors()
220        self.recalculateScore()
221        self.reindexObject()
222
223
224    security.declarePrivate('post_edit_credit_author')
225    def post_edit_credit_author(self):
226        mtool = getToolByName(self, 'portal_membership')
227        memberfolder=mtool.getHomeFolder()
228        if memberfolder!=None:
229            memberfolder.recalculateScore()
230
231    security.declarePrivate('post_edit_rename')
232    def post_edit_rename(self):
233        # Store current ID
234        old_id = self.getId();
235        # See what would be the optimal ID currently
236        optimal_new_id = self.generateNewId()
237        if optimal_new_id and optimal_new_id != old_id:
238            execute_rename=True
239            if old_id.startswith(optimal_new_id):
240                # Check that the old_id isn't optimal + "-n" suffix
241                suffix = old_id[len(optimal_new_id):]
242                if suffix[0]=='-' and suffix[1:].isdigit():
243                    execute_rename=False
244            # Attempt rename, adding "-n" so that no duplicates occur
245            if execute_rename:
246                # Ff the new name is a redirector for this object, delete it
247                ob = getattr(self.aq_inner.aq_parent,optimal_new_id,None)
248                if ob and ob.meta_type == 'Redirector':
249                    if ob.redirect_to == self.UID():
250                        self.aq_inner.aq_parent._delObject(optimal_new_id)
251                # Execute rename
252                self._renameAfterCreation()
253                new_id = self.getId();
254                # If the id did change
255                if old_id != new_id and not is_id_in_default_format(old_id):
256                    # Need to create redirection from old URL
257                    red = Redirector(old_id)
258                    red.redirect_to = self.UID()
259                    self.aq_inner.aq_parent._setObject(old_id,red)
260   
261    def dumpme(self,item):
262        """ print """
263        print 'dump: %s' % item
264
265    def amIListType(self, s):
266        """ Checks if s is a list """
267        if type(s) == list:
268            return True
269        else:
270            return False
271
272    def checkTitle(self, obj=None ,title='', objtype=''):
273        """ check if title is not used anywhere in not(deleted, redirector) object, return false if it is """
274        lt=getToolByName(self, 'lemill_tool')
275        return lt.checkTitle(self,obj=obj, title=title, objtype=objtype)
276
277    def shortenedTitle(self, max_length):
278        title = self.Title()
279        if len(title)<=max_length:
280            return title
281        while len(title)>max_length:
282            title=title[:title.rfind(' ')]
283        return title+"..."           
284
285    security.declareProtected(MANAGE_PORTAL, 'refresh_author')
286    def refresh_author(self, REQUEST):
287        """run private recalc script"""
288        self.recalculateAuthors()
289        return REQUEST.RESPONSE.redirect(self.absolute_url())
290
291
292    def Creator(self):
293        """This method is part of Plone Dublin Core.
294        Overridden to give correct values."""
295        auth = self.getAuthors()
296        if len(auth)>0:
297            return self.getAuthors()[0]
298        else:
299            return ''
300
301    def Creators(self):
302        """This is another base method that should provide good values."""
303        return self.getAuthors()
304
305    def Contributors(self):
306        """This is another base method that should provide good values
307        (everyone except first author)"""
308        auth = self.getAuthors()
309        if len(auth)>0:
310            return self.getAuthors()[1:]
311        else:
312            return []
313
314    def amIMaterial(self):
315        """ Returns True if it's a material """
316        return False
317
318    def amIOwner(self):
319        """ check owner of object """
320        roles = self.portal_membership.getAuthenticatedMember().getRolesInContext(self)
321        return 'Owner' in roles
322
323    def canIModerate(self):
324        roles = self.portal_membership.getAuthenticatedMember().getRolesInContext(self)
325        return 'Manager' in roles or 'Reviewer' in roles
326 
327    def getObjectByUID(self, UID=None):
328        """ Return object with this UID or None """
329        uc=getToolByName(self, 'uid_catalog')
330        objlist=uc({'UID':UID})
331        if objlist:
332            return objlist[0].getObject()
333        return None
334
335    def getAuthors(self):
336        """ used to get the list of authors """
337        return self.getField('creators').get(self)
338
339    def getUserGroups(self, allow_none=True):
340        """ this is vocabulary for groups field """
341        mtool = getToolByName(self, 'portal_membership')
342        memberfolder=mtool.getHomeFolder()
343        groups=memberfolder.getGroups()
344
345        if allow_none:
346            mess_not_assigned = _(u'Not assigned to any group')
347            result = [('no_group', mess_not_assigned)]
348        else:
349            result = []
350        for g in groups:
351            result.append((g.UID, g.Title))
352        mess_create_new_group = _(u'...or create a new group:')
353        result.append(('__new_group', mess_create_new_group))
354        return result
355
356
357    def getAuthorsNames(self):
358        """ Get nice user names of authors. """
359        names = []
360        authors = self.getAuthors()
361        for author in authors:
362            auth = author.split(',')
363            for a in auth:
364                try:
365                    member=self.getMember(a)
366                    name = member.NiceName()
367                    names.append(name)
368                except AttributeError:
369                    names.append(a)
370        if not names:
371            return ""
372        return ', '.join(names)
373
374    def getLanguagelist(self):
375        languagelist=self.availableLanguages()
376        return DisplayList(languagelist)
377
378    security.declarePublic('defaultLanguage')
379    def defaultLanguage(self):
380        """ Get logged users default language """
381        member = self.getMember()
382        if member:
383            #lang=member.getLanguage_skills()
384            try:
385                lang=member.getField('language_skills').get(member)
386                if lang:
387                    return lang[0]
388            except AttributeError:
389                raise 'Invalid User',"%s - %s" % (self.whoami(),str(member))
390        return ''
391
392
393    def _prettyFieldName(self, fieldname):
394        """ return a widget's label """
395        f = self.getField(fieldname)
396        if f is None:
397            return fieldname
398        return f.widget.Label(self)
399
400
401
402    security.declareProtected(ModerateContent, 'publish')
403    def publish(self):
404        """ Publish object. Called by script_changeCoverImage.cpy """
405        wtool = getToolByName(self, 'portal_workflow')
406        self.content_status_modify(workflow_action='publish',msg='Resource published')
407        # When publishing a learning resource, the media pieces will inherit the title and tags of their parent learning resource
408        for bodyElement in self.getBodyText():
409            if self.isUid(bodyElement):
410                obj = self.getObjectByUID(bodyElement)
411                if obj:
412                    newTags = obj.getTags()
413                    for tag in self.getTags():
414                        if not tag in newTags:
415                            newTags += (tag,)
416                    if obj.title_or_id().startswith(self.title_or_id() + " - "):
417                        newTitle = obj.title_or_id()
418                    else:
419                        newTitle = self.title_or_id() + " - " + obj.title_or_id()
420                    obj.edit(tags = newTags, title = newTitle)
421
422    security.declareProtected(ModerateContent, 'retract')
423    def retract(self):
424        """Retract published, but keep coverimage as it is"""
425        self.content_status_modify(workflow_action='retract', msg='Resource changed back to draft status')
426        return self
427
428
429    def review_state(self):
430        """ shortcut to portal_workflow's review state """
431        wtool = getToolByName(self, 'portal_workflow')
432        return wtool.getInfoFor(self,'review_state',None)   
433
434    security.declareProtected(MANAGE_PORTAL, 'permaDelete')
435    def permaDelete(self, REQUEST):
436        """Move to trash"""
437        if not self.review_state() == 'deleted':
438            return self
439        portal_url = getToolByName(self, 'portal_url')
440        id=self.id
441        context=self.aq_parent
442        portal = portal_url.getPortalObject()
443        trash=portal.trash
444        trash.manage_pasteObjects(context.manage_cutObjects(id))
445        moved=getattr(trash, id)
446        moved.setId(moved.UID())
447        moved.unindexObject()
448        plone_utils=getToolByName(self, 'plone_utils')
449        plone_utils.addPortalMessage(PMF(u'Moved item to trash.'))
450        return REQUEST.RESPONSE.redirect(context.absolute_url())
451
452
453    security.declareProtected(ModerateContent, 'deleteResource')
454    def deleteResource(self, reason=''):
455        """Set reason for deletion, set state to deleted and update catalog"""
456        f = self.getField('deletionReason')
457        f.set(self, reason)
458        self.content_status_modify(workflow_action='delete', msg='Resource deleted')
459        if self.meta_type in MATERIAL_TYPES:
460            self.aliases['(Default)']='base_view'
461        self.reindexObject()
462
463    security.declareProtected(ModerateContent, 'rescue')
464    def rescue(self):
465        """Undelete a resource """
466        if self.meta_type in MATERIAL_TYPES:
467            self.aliases['(Default)']='fullscreen_view'
468        self.undeleteResource()
469
470    security.declareProtected(ModerateContent, 'undeleteResource')
471    def undeleteResource(self):
472        f=self.getField('deletionReason')
473        f.set(self, None)
474        portal_workflow=getToolByName(self, 'portal_workflow')
475        transitions = [x['id'] for x in portal_workflow.getTransitionsFor(self)]
476        if 'restore' in transitions:
477            self.content_status_modify(workflow_action='restore', msg='Resource restored1')
478        elif 'publish' in transitions:
479            self.content_status_modify(workflow_action='publish', msg='Resource restored2')
480        else:
481            plone_utils=getToolByName(self, 'plone_utils')
482            plone_utils.addPortalMessage(PMF(u'Restoration failed.'))
483        self.reindexObject()
484
485
486
487    def download(self, REQUEST, RESPONSE, field):
488        """ download a file """
489        from Products.Archetypes.utils import contentDispositionHeader
490        org_filename = field.getFilename(self)
491        filename = self.Title()
492        pu = getToolByName(self, 'plone_utils')
493        filename = pu.normalizeString(filename)
494        extension = ''
495        if org_filename:        # extract .doc .pdf or something
496            extension = org_filename[org_filename.rfind('.'):]
497            if extension == -1: extension = ''
498        else:                   # try to guess extension
499            ct = field.getContentType(self)
500            mr = getToolByName(self, 'mimetypes_registry')
501            mt = mr.lookup(ct)
502            if mt:              # mt is something like (<mimetype text/plain>,) so we'll take first one
503                extension = mt[0].extensions[0]       # and take first one from here too
504                extension = '.'+extension
505        if extension:
506            filename += extension
507        header_value = contentDispositionHeader('attachment', self.getCharset(), filename=filename)
508        RESPONSE.setHeader("Content-disposition", header_value)
509        file = field.get(self)
510        return file.index_html(REQUEST, RESPONSE)
511
512    def getCollections(self):
513        """ Show collections where object is used """
514        obj_uid = self.UID()
515        res = []
516        q = { 'targetUID': obj_uid }
517        qres = self.reference_catalog(q)
518        for q in qres:
519            v = self.reference_catalog.lookupObject(q.UID)
520            source = v.getSourceObject()
521            if source.meta_type == 'Collection':
522                 res.append(source)
523        return res
524   
525   
526    def isUid(self, chapter):
527        """This is a wrapper to reach ChapterField.isUid()"""
528        return self.getField('bodyText').isUid(chapter)
529
530
531    #########################
532    ###   History  stuff  ###
533    #########################
534
535    security.declarePrivate('post_edit_update_history')
536    def post_edit_update_history(self):
537        history=self.getHistory()
538        changedFields=[]
539        schema=self.Schema()
540
541        if not history:
542            # First edit - object just created
543            for field in schema.editableFields(self):
544                fieldname=field.getName()
545                #if fieldname in form.keys():
546                changedFields.append(fieldname)
547        else:
548            # Subsequent edit - history exists
549            for field in schema.editableFields(self):
550                fieldname=field.getName()
551                #if fieldname in form.keys():
552                if field.getRaw(self) != \
553                       self.__getLatestHistoricalValueForField(fieldname):
554                    changedFields.append(fieldname)
555        if [x for x in changedFields if x not in ('creators', 'modification_date')]:
556            hist = self.getHistory()
557            if hist and hist[0]['_by'] == self.whoami() and time.time() - hist[0]['_timestamp'] < 3600:   # within one hour
558                prev_changes = hist[0].keys()
559                del hist[0]
560                changedFields = [x for x in set(changedFields + prev_changes) if x[0] != '_']
561            self.storeInHistory(changedFields)
562
563    security.declareProtected(MODIFY_CONTENT, 'restoreAVersion')
564    def restoreAVersion(self, timestamp):
565        """Restores an old version of the resource."""
566        histcopy=list(self.getHistory())
567        for x in histcopy:
568            if round(x['_timestamp']) == round(float(timestamp)):
569                changedFields = []
570                schema = self.Schema()
571                for field in schema.editableFields(self):
572                    if x.has_key(field.getName()):
573                        changedFields.append(field.getName())
574                        self.getField(field.getName()).set(self, x[field.getName()])
575                self.storeInHistory(changedFields)
576        if self.portal_type in MATERIAL_TYPES:
577            return self.REQUEST.RESPONSE.redirect('%s/view' % self.absolute_url())
578        else:
579            return self.REQUEST.RESPONSE.redirect(self.absolute_url())
580
581    def __getLatestHistoricalValueForField(self,fieldname):
582        for entry in self.getHistory():
583            if fieldname in entry.keys():
584                return entry[fieldname]
585        return None
586
587    def _storeInHistory(self, entry, timestamp, by=None, summary=None):
588        entry['_by'] = by or ''
589        entry['_timestamp'] = timestamp
590        entry['_summary'] = summary or ''
591        # Newest first!
592        self.getHistory().insert(0, entry)
593
594    security.declarePrivate('storeInHistory')
595    def storeInHistory(self,fields,summary=None,storeAuthor=True):
596        data = dict((key, self.getField(key).getRaw(self)) for key in fields)
597        self._storeInHistory(data, time.time(), storeAuthor and self.whoami(), summary)
598
599    def getTimeForOldHistory(self, timestamp):
600        """ retruns time for the history """
601        timestamp = float(timestamp)
602        historyTime = time.asctime(time.localtime(timestamp))
603        return historyTime
604
605    def getHistoricalFields(self, timestamp, previous=False):
606        """ get historical fields. changed fields from timestamp(including), if previous=True, return previous entry instead """
607        history = self.getHistory()
608        entry = {}
609        curr_timest = 0
610        rec = 0
611        x_previous=history[0]
612        for x in history:
613
614            if float(timestamp) >= float(x['_timestamp']):  # scroll back in time
615                rec = 1                                 # when requested time is here, start looking for fields
616            if not rec:
617                x_previous=x
618                continue
619            if previous:
620                for k in x_previous.keys():
621                    if k.startswith('_'): continue
622                    if entry.has_key(k): continue
623                    entry[k] = x_previous[k]
624            else:
625                for k in x.keys():
626                    if k.startswith('_'): continue
627                    if entry.has_key(k): continue
628                    entry[k] = x[k]
629
630        fields = self.Schema().editableFields(self)
631        for field in fields:
632            fieldname=field.getName()
633            if entry.has_key(fieldname): continue
634            f = self.getField(fieldname)
635            entry[fieldname] = f.getRaw(self)
636        return entry
637
638    def getFieldHistory(self, field, timestamp):
639        """ get ... """
640        history = self.getHistory()
641        for x in history:
642            timestamp = float(timestamp)
643            if round(x['_timestamp']) == round(timestamp):
644                try:
645                    return x[field]
646                except KeyError:
647                    break
648        # there's nothing, scroll back in time
649        rec = 0
650        for x in history:
651            if float(timestamp) >= float(x['_timestamp']):  # scroll back in time
652                rec = 1
653            if not rec: continue
654            if x.has_key(field): return x[field]
655        return "get error, no history?"
656
657    def getDiffFields(self, old_version):
658        """ will return one version of document by timestamp """
659        obj_x = self.getHistoricalFields(old_version)
660        obj_y = self.getHistoricalFields(old_version, previous=True)
661        keys = []
662        diffs = {}
663        for x in obj_x.keys():                  # I don't think it's necessary to merge but you never know
664            if x not in keys: keys.append(x)
665        for x in obj_y.keys():
666            if x not in keys: keys.append(x)
667        for x in keys:
668            if not obj_x.has_key(x):
669                obj_x[x] = None
670            elif not obj_y.has_key(x):
671                obj_y[x] = None
672
673            if obj_x[x] == obj_y[x]: continue
674            if not obj_x[x] and not obj_y[x]: continue # '' vs. None
675            if x == 'modification_date': continue
676            if x == 'coverImage': continue
677            entry = {}
678            entry['name'] = x
679            entry['old'] = obj_x[x]
680            entry['new'] = obj_y[x]
681            diffs[x] = entry
682        for x in diffs.keys():
683            diffs[x]['niceName'] = self._prettyFieldName(diffs[x]['name'])
684        return diffs
685
686    security.declarePrivate('getHistory')
687    def getHistory(self):
688        try:
689            return self.__history
690        except AttributeError:
691            self.__history=PersistentList()
692            return self.__history
693
694    security.declarePrivate('setHistory')
695    def setHistory(self, history):
696        self.__history=history
697
698
699    def getLastEditor(self):
700        """Returns id of last user who edited this object"""
701        try:
702            return self.getHistory()[0]['_by']
703        except IndexError:
704            #print 'bad history, %s: %s' % (self.getId(), self.getHistory())
705            return ''
706
707    def getLatestEditDate(self):
708        """Returns date of last edit or creation date if fails"""
709        hist=self.getHistory()
710        if len(hist)>0:
711            hist=hist[0]
712            if hist.has_key('_timestamp'):
713                return DateTime(hist['_timestamp'])
714        #print 'bad history, %s: %s' % (self.getId(), self.getHistory())
715        return DateTime(self.CreationDate())
716       
717    def getHistoryEntries(self):
718        """Return list of history entries for viewing."""
719        history=self.getHistory()
720        entries=[]
721        wtool = getToolByName(self,'portal_workflow')
722        sort_history = [(event['_timestamp'],event) for event in history]
723        sort_history.sort()
724        history = [event for ts, event in sort_history]
725        for event, version in zip(history, count(1)):
726            entry = {}
727            entry['version'] = version
728            entry['date']=time.asctime(time.localtime(event['_timestamp']))
729            entry['timestamp'] = event['_timestamp']
730            other_member = self.getMember(event['_by'])
731            entry['author']= other_member and other_member.Creator() or ""
732            if '_summary' in event.keys() and event['_summary']:
733                entry['summary']=event['_summary']
734            elif version == 1:
735                entry['summary']="Resource created"
736            else:
737                fields = [self._prettyFieldName(x) for x in event.keys() if not x.startswith('_')]
738                # this line has thrown UnicodeError
739                # XXX: this can screw up everything.
740                #fields = [self._prettyFieldName(x).decode('latin1').encode('utf-8') for x in event.keys() if not x.startswith('_')]
741                mod = ', '.join(fields)
742                entry['summary']="Modified these: %s" % mod
743            entries.append(entry)
744        entries.reverse()
745        return entries
746
747
748    security.declarePrivate('migrate_history')
749    def migrate_history(self):
750        """ migrate_history """
751        hist = self.getHistory()
752        if len(hist) == 0:
753            return
754
755        prev_timestamp = hist[-1]['_timestamp']
756        prev_user = hist[-1]['_by']
757        changed_fields = set(x for x in hist[-1].iterkeys() if x[0] != '_')
758
759        old_hist = list(hist)
760        del hist[:]
761
762        def collect_fields(hist, fields):
763            fields = set(fields)    # it has to be copied as it's destroyed...
764            result = {}
765            for entry in hist:
766                if not fields:
767                    break
768
769                common_fields = fields & set(entry.iterkeys())
770                assert not sum(key in result.keys() for key in common_fields)
771                result.update((key, entry[key]) for key in common_fields)
772                fields -= common_fields
773
774            assert not fields, fields
775            return result
776
777        def add_to_history(old_history, fields):
778            data = collect_fields(old_history, fields)
779            self._storeInHistory(data, old_history[0]['_timestamp'], old_history[0]['_by'], old_history[0]['_summary'])
780
781        for i, entry in zip(count(1), reversed(old_hist[:-1])):
782            f = set(x for x in entry.iterkeys() if x[0] != '_')
783            if [x for x in f if x not in ('creators', 'modification_date')]:
784                if prev_user != entry['_by'] or entry['_timestamp'] - prev_timestamp >= 3600:
785                    add_to_history(old_hist[-i:], changed_fields)
786                    prev_timestamp = entry['_timestamp']
787                    prev_user = entry['_by']
788                    changed_fields = f
789                else:
790                    changed_fields |= f
791        add_to_history(old_hist, changed_fields)
792
793
794    security.declarePrivate('recalculateAuthors')
795    def recalculateAuthors(self, removeAdmin=''):
796        """ Recalculates author order """
797        creators_field = self.getField('creators')
798        creators=creators_field.get(self)
799
800        if self.review_state() == 'draft':
801            #print 'draft'
802            return creators
803
804        hist_entries = self.getHistoryEntries()
805        if not hist_entries:
806            #print 'no history entries'
807            return creators
808
809        l = [(ev['timestamp'], ev['author']) for ev in hist_entries if ev['author']]
810        if not l:
811            #print 'no timestamps'
812            return creators
813        original_creator = min(l)[1]
814        now=time.time()
815
816        diffsort = []
817        for event in hist_entries[:-1]:     # no diff for the original version
818            diff = self.getDiffFields(event['timestamp'])
819            if now-event['timestamp']<3600 and event['author']==removeAdmin:
820                print 'Ignored admin modification in %s, made %s seconds ago.' % (self.getId(), int(now-event['timestamp']))
821            else:                                       
822                if diff.has_key('bodyText'):
823                    # ignore modifications by removeAdmin during last hour
824                    old = diff['bodyText']['old']
825                    new = diff['bodyText']['new']
826                    if type(old)==list:
827                        o2=[]
828                        for o in old:
829                            if type(o)==list:
830                                o='\n'.join(o)
831                            o2.append(o)
832                        old='\n'.join(o2)
833                    old=old.split('\n')
834                    if type(new)==list:
835                        n2=[]
836                        for n in new:
837                            if type(n)==list:
838                                n='\n'.join(n)
839                            n2.append(n)
840                        new='\n'.join(n2)
841                    new=new.split('\n')
842                    d = difflib.unified_diff(old, new)
843                    first_chars = [l[0] for l in list(d)[2:]]
844                    plus = first_chars.count('+')
845                    minus = first_chars.count('-')
846                    context = first_chars.count(' ')
847                    chunk = first_chars.count('@')
848                    diffsort.append(((plus - minus, plus), event['author']))
849
850        diffsort.sort()
851        new_creators = remove_duplicates([creator for cmp_val, creator in diffsort])
852        if original_creator in new_creators:
853            new_creators.remove(original_creator)
854        new_creators.insert(0, original_creator)
855        creators_field.set(self, new_creators)
856        return new_creators
857
858    def getRelatedStories(self):
859        """ Returns good Collections with stories """
860        pc = getToolByName(self, 'portal_catalog')
861        query = {'getRawRelatedContent':self.UID(),
862            'getGoodStory':True,
863            'meta_type':'Collection',
864            'sort_on':'Date',
865            'sort_order':'descending'}
866        results = pc.searchResults(query)
867        results = [x for x in results if x.review_state!='deleted']
868        return results
869
870
871
872
873InitializeClass(Resource)
874
875class LearningResource(Resource):
876    """Superclass for all learning resources (material, activity, tool)."""
877
878    security = ClassSecurityInfo()
879
880    def manage_afterAdd(self, item, container):
881        Resource.manage_afterAdd(self, item, container)
882
883    security.declarePrivate('at_post_create_script')
884    def at_post_create_script(self):
885        # check if this is a translation of some object
886        if hasattr(self, 'getTranslation_of'):
887            trans_of=self.getTranslation_of()
888            if trans_of:
889                trans_of.addToTranslations(self.UID())
890                wftool = getToolByName(self, 'portal_workflow')
891                assert wftool.getInfoFor(self,'review_state',None)=='deleted'
892                transitions = [x['id'] for x in wftool.getTransitionsFor(self)]
893                if 'restore' in transitions:
894                    wftool.doActionFor(self, 'restore')
895                elif 'publish' in transitions:
896                    wftool.doActionFor(self, 'publish')
897        self.at_post_edit_script()
898
899    def __updateCoverImage(self):
900        # Set cover image
901        refs = None
902        try:
903            refs = self.getField('refsToImages').get(self)
904        except AttributeError: # just in case...
905            return
906        cover=self.getField('coverImage')
907        has_cover=self.getField('hasCoverImage')
908        if refs:
909            first=refs[0]
910            value = first.getField('file').get(first)
911            cover.set(self,value)
912            has_cover.set(self,True)
913        else:
914            cover.set(self,None)
915            has_cover.set(self,False)
916
917    security.declarePublic('canIEdit')
918    def canIEdit(self):
919        mtool = getToolByName(self, 'portal_membership')
920        lmtool = getToolByName(self, 'lemill_usertool')
921        group = self.getGroupsEditing()
922        if not group:
923            return True # This should happen often
924        member = mtool.getAuthenticatedMember()
925        if not member:
926            return False # This shouldn't happen at all
927
928        membersgroups=member.getGroups()
929        if not membersgroups:
930            return False
931        return group.id in [m.id for m in membersgroups]
932
933       
934    security.declareProtected(MODIFY_CONTENT, 'setGroupsEditing')
935    def setGroupsEditing(self, value):
936        """ share a material/resource with a group(s) """
937        reftool= getToolByName(self, 'reference_catalog')
938        create_new = self.REQUEST.get('new_group_name', '')
939        blog=None
940        if create_new and value=='__new_group':
941            # create a new group here
942            from Products.CMFPlone.utils import normalizeString
943            new_group_id = normalizeString(create_new, context=self)
944            blog_id=self.community.invokeFactory('GroupBlog',new_group_id)
945            blog=getattr(self.community, blog_id, None)
946            blog.join_group()
947            blog.edit(title=create_new, description="")
948            value = blog.UID()
949            discussion=self.getDiscussion(do_create=False)
950            if discussion:
951                self.moveDiscussion(discussion, blog)               
952
953        if value == '__new_group':
954            return
955        f = self.getField('groupsEditing')
956        old_value = f.get(self)
957        destination=None
958        if value:
959            destination = self.getObjectByUID(value)
960        else:
961            destination=self.community.unassigned_discussions
962
963        if value!=old_value:
964            f.set(self, value)
965            discussion=self.getDiscussion(do_create=False)
966            if discussion and destination:
967                self.moveDiscussion(discussion, destination)
968
969    #########################
970    ###    Discussions    ###
971    #########################
972
973    def moveDiscussion(self, discussion, destination):
974        """ moves a discussion from one group to another, called when resource changes groups """
975        reftool= getToolByName(self, 'reference_catalog')
976        source=discussion.aq_parent
977        reftool.deleteReference(discussion, self,  'is_discussion_about')
978        destination.manage_pasteObjects(source.manage_cutObjects(discussion.id))
979        discussion=getattr(destination, discussion.id)
980        reftool.addReference(discussion, self, 'is_discussion_about')
981
982
983    def getDiscussion(self, do_create=True):
984        """get discussion object or create one """
985        # If discussion exists, find it
986        reftool= getToolByName(self, 'reference_catalog')
987        obj_uid = self.UID()
988        found = []
989        ref_query = { 'targetUID': obj_uid, 'type': 'is_discussion_about' }
990        refresults = reftool(ref_query)
991        for q in refresults:
992            refobject = reftool.lookupObject(q.UID)
993            source = refobject.getSourceObject()
994            if source.meta_type == 'BlogPost':
995                 found.append(source)
996        if found:
997            return found[0]
998        elif not do_create:
999            return None           
1000        # If no discussion found and we do_create, then we create one
1001        group=self.getGroupsEditing()
1002        if not group:
1003            group=self.community.unassigned_discussions
1004
1005        body=self.defaultDiscussionMessage()
1006        title="Discussion about %s" % self.Title()
1007        post_id = self.generateUniqueId('BlogPost')
1008        post_id=group.invokeFactory('BlogPost',post_id)
1009        post=getattr(group, post_id, None)
1010        post.edit(bodyText=body, is_discussion=True, title=title)
1011        post._renameAfterCreation()
1012        reftool.addReference(post, self, 'is_discussion_about')
1013        return post
1014
1015    def defaultDiscussionMessage(self, bodytext=None):
1016        """ return bodytext to default message or compare it with given bodytext
1017         (if they match we can put i18n:tags around it)"""
1018        body="""Here you can discuss about <a href="%s/view">%s</a>.""" % (self.absolute_url(), self.Title())
1019        if bodytext:
1020            return body==bodytext
1021        else:
1022            return body     
1023
1024
1025    #########################
1026    ### Translation stuff ###
1027    #########################
1028
1029    # also in LargeSectionFolder:
1030    # def start_translation(self, objId=None):
1031
1032    # and scripts:
1033    # createTranslation.cpy
1034
1035    # getTranslations, getTranslation_of and setTranslations, setTranslation_of are basic archetype generated methods
1036
1037    security.declarePublic('languagesNotTranslated')
1038    def languagesNotTranslated(self):
1039        """ List of languages minus list of existing translations """
1040        transcodes = [x.Language() for x in self.getTranslationsOfOriginal()]
1041        # make sure that original language isn't one of translation options
1042        mother=self.getOriginal()
1043        if mother:
1044            transcodes.append(mother.Language())
1045        else:
1046            transcodes.append(self.Language())
1047        langs = self.availableLanguages()[1:]
1048        return [x for x in langs if x[0] not in transcodes and x[0] != self.Language()]
1049
1050
1051    security.declarePublic('getFieldsToTranslate')
1052    def getFieldsToTranslate(self):
1053        """ Returns fields to show from original when translating, uses list of untranslateables as guide """
1054        fields= self.Schema().filterFields(isMetadata=0)
1055        lif= LANGUAGE_INDEPENDENT_FIELDS+['language', 'translation_of', 'translations']
1056        fields = [x for x in fields if x.__name__ not in lif]
1057        return fields
1058       
1059    security.declarePublic('getTranslationsOfOriginal')
1060    def getTranslationsOfOriginal(self, include_self=True):
1061        """ Returns translations from mother or if no such thing, translations from self. """
1062        if not hasattr(self, 'getTranslation_of'):
1063            return []
1064        wtool = getToolByName(self,'portal_workflow')
1065        mother=self.getTranslation_of()
1066        if mother:
1067            listoft= mother.getField('translations').get(mother)
1068            if include_self:
1069                listoft.append(mother)
1070        else:
1071            listoft= self.getField('translations').get(self)
1072            if include_self:
1073                listoft.append(self)
1074        goodlist=[]
1075        goodlist=[x for x in listoft if wtool.getInfoFor(x,'review_state',None)!='deleted' and x]
1076        if goodlist!=listoft:
1077            if mother:
1078                mother.setTranslations(goodlist)
1079            else:
1080                self.setTranslations(goodlist)
1081        return goodlist
1082
1083    def getOriginal(self):
1084        """ accessor plus check if deleted """
1085        wtool = getToolByName(self,'portal_workflow')
1086        if hasattr(self, 'getTranslation_of'):
1087            mother=self.getTranslation_of()
1088            if mother:
1089                if wtool.getInfoFor(mother,'review_state',None)!='deleted':
1090                    return mother
1091        return False
1092
1093    def prefill_translation(self, to_language='en', base_obj=None):
1094        """ Copy values of some fields to new object and set translation-references """
1095        if base_obj is None:
1096            raise 'base object for translation is not found (prefill_translation)'
1097        mtool = getToolByName(self, 'portal_membership')
1098        wftool = getToolByName(self, 'portal_workflow')
1099        portal_url = getToolByName(self, 'portal_url')
1100        lif=LANGUAGE_INDEPENDENT_FIELDS       
1101
1102        new=self
1103        wftool.doActionFor(new, 'delete')
1104
1105        import copy
1106        for k in base_obj.schema.keys():
1107            # list all fields here that shouldn't be copyied to new object
1108            if k not in lif:
1109                continue
1110            old_accessor = base_obj.schema[k].getEditAccessor(base_obj)
1111            new_mutator = new.schema[k].getMutator(new)
1112            if not old_accessor:
1113                continue
1114            val = old_accessor()
1115            copied_val = None
1116            try:
1117                copied_val = copy.copy(val)
1118            except TypeError:
1119                copied_val = copy.copy(val.aq_base)
1120            new_mutator(copied_val)
1121
1122        realbase=base_obj.getTranslation_of() # if this is a translation of translation
1123        if not realbase:
1124            realbase=base_obj
1125        baseuid=realbase.UID()
1126        realbase.addToTranslations(new.UID())
1127        new.setTranslation_of(baseuid)
1128        new.setLanguage(to_language)
1129        author=mtool.getAuthenticatedMember().getId()
1130        new.setCreators([author])
1131        new.storeInHistory({}, summary='Translation of %s created' % base_obj.title_or_id())
1132               
1133        return new       
1134
1135    def addToTranslations(self, UID):
1136        """ Makes sure that obj UID is in translations, but doesn't check if this is mother of translations"""
1137        #print 'add to translations with obj %s and UID %s' % (self, UID)
1138        trans_list = self.getTranslations()
1139        trans_list = [x.UID() for x in trans_list if x!=None]
1140        if UID not in trans_list:
1141            trans_list.append(UID)
1142            self.setTranslations(trans_list)       
1143
1144    def removeFromTranslations(self, UID):
1145        """ remove """
1146        trans_list = self.getTranslations()
1147        trans_list = [x.UID() for x in trans_list if x!=None]
1148        if UID in trans_list:
1149            trans_list.remove(UID)
1150            self.setTranslations(trans_list)       
1151
1152
1153    security.declareProtected(MANAGE_PORTAL, 'manage_form_setTranslationOf')
1154    def manage_form_setTranslationOf(self, REQUEST):
1155        """ Allows managers to reorganize which is translated by whom """
1156        ptool = getToolByName(self, "plone_utils")
1157        mothers_id = REQUEST.get('mother_field')
1158        folder = self.getSectionFolder()
1159        old_mother=self.getTranslation_of()
1160        if mothers_id=='' and old_mother:
1161            self.setTranslation_of('')
1162            old_mother.removeFromTranslations(self.UID)
1163        if hasattr(folder, mothers_id):
1164            new_mother = getattr(folder, mothers_id)
1165            if new_mother.portal_type == self.portal_type:
1166                self.setTranslation_of(new_mother)
1167                new_mother.addToTranslations(self.UID())
1168            else:
1169                ptool.addPortalMessage(_(u"Type mismatch, cannot be translation of that kind of object."))
1170        else:
1171            ptool.addPortalMessage(_(u"Object not found."))       
1172        return REQUEST.RESPONSE.redirect('%s/manage_translations' % self.absolute_url())
1173
1174    security.declareProtected(MANAGE_PORTAL, 'manage_form_setTranslations')
1175    def manage_form_setTranslations(self, REQUEST):
1176        """ Allows managers to reorganize which is translated by whom """
1177        ptool = getToolByName(self, "plone_utils")
1178        oldlist=self.getTranslations()
1179        oldids=[x.id for x in oldlist]
1180        folder = self.getSectionFolder()
1181       
1182        for n in range(len(oldlist)):
1183            new_id = REQUEST.get('obj_translation%s' % n)
1184            old_id = REQUEST.get('obj_translation_old%s' % n)
1185            if new_id != old_id:
1186                if hasattr(folder, new_id):
1187                    new = getattr(folder, new_id)
1188                    if new.portal_type == self.portal_type:
1189                        self.addToTranslations(new.UID())
1190                        new.setTranslation_of(self)
1191                    else:
1192                        ptool.addPortalMessage(_(u"Type mismatch, cannot be translation of that kind of object."))
1193                else:
1194                    ptool.addPortalMessage(_(u"Object not found."))       
1195                if hasattr(folder, old_id):
1196                    old = getattr(folder, old_id)
1197                    self.removeFromTranslations(old.UID())
1198                    old.setTranslation_of('')
1199
1200        return REQUEST.RESPONSE.redirect('%s/manage_translations' % self.absolute_url())
1201
1202
1203
1204
1205    security.declareProtected(MANAGE_PORTAL, 'manage_form_addTranslation')
1206    def manage_form_addTranslation(self, REQUEST):
1207        """ Allows managers to reorganize which is translated by whom """
1208        ptool = getToolByName(self, "plone_utils")
1209        new_addition = REQUEST.get('new_translation')
1210        if not new_addition:
1211            return REQUEST.RESPONSE.redirect('%s/manage_translations' % self.absolute_url())
1212        folder = self.getSectionFolder()
1213        if hasattr(folder, new_addition):
1214            new = getattr(folder, new_addition)
1215            if new.portal_type == self.portal_type:
1216                self.addToTranslations(new.UID())
1217                new.setTranslation_of(self)
1218            else:
1219                ptool.addPortalMessage(_(u"Type mismatch, cannot be translation of that kind of object."))
1220        else:
1221            ptool.addPortalMessage(_(u"Object not found."))       
1222        return REQUEST.RESPONSE.redirect('%s/manage_translations' % self.absolute_url())
1223
1224    def recalculateScore(self):
1225        """  Recalculates score for LearningResources according to specifications"""
1226        score = 0
1227        collections = self.getCollections()
1228        stories = self.getRelatedStories()
1229        different_members = []
1230
1231        for c in collections:
1232            if c.Creator() not in different_members:
1233                different_members.append(c.Creator())
1234
1235        for member in different_members:
1236            score = score + 1
1237
1238        for story in stories:
1239            score = score + 10
1240
1241        # Let's make sure that score is at least 1
1242        if score<1:
1243            score = 1
1244        # Set the value for field score
1245        self.setScore(score)
1246
1247
1248InitializeClass(LearningResource)
1249
1250class Redirector(BaseContent,CommonMixIn):
1251    """Redirects to new URLs of renamed resources."""
1252
1253    meta_type = "Redirector"
1254    archetype_name = "Redirector"
1255
1256    aliases = {
1257        '(Default)' : 'redirect',
1258        'view'      : 'redirect',
1259    #    'edit'      : 'redirect',
1260    #    'base_view' : 'redirect',
1261    #    'history_view': 'redirect',
1262    }
1263
1264    def __bobo_traverse__(self, REQUEST, entry_name=None):
1265        """ redirect to correct object """
1266        obj = self.redirect()
1267        if entry_name is None:
1268            return obj
1269        try:
1270            l = getattr(obj, entry_name)
1271        except AttributeError:
1272            return BaseContent.__bobo_traverse__(self, REQUEST, entry_name)
1273        if hasattr(l, 'im_func'):
1274            return l()
1275        return l
1276
1277    def redirect(self, REQUEST=None):
1278        """Redirect to current location."""
1279        rc = getToolByName(self, 'reference_catalog')
1280        return rc.lookupObject(self.redirect_to, REQUEST)
1281
1282registerType(Redirector)
Note: See TracBrowser for help on using the repository browser.