root/pdw/model/frbr.py @ 326:46fe057f2605

Revision 326:46fe057f2605, 17.5 KB (checked in by acracia, 6 months ago)

[api]to_dict method on the model, tailored to use as input of the api

Line 
1'''Domain Model based on FRBR.
2
3(For info on FRBR see docs/frbr.txt)
4 
5Item (Release): corresponds to a published item (not the individual item but that
6particular run -- i.e. something with e.g. an ISBN). Item usually
7corresponds to a single work but can contain many works.
8
9Work: Represents the underlying work. So, for example, different published
10editions (or recordings) of Beethoven\'s Ninth Symphony will all have an
11associated underlying work "Beethoven\'s Ninth Symphony".
12
13Person: any kind of entity
14
15Persons are associated to Works and Items in a many 2 many relationship with a
16role attribute.
17  * We also allow the same person to be associated with the same work/item many
18    times (to allow for the possibility that the same person has multiple
19    roles).
20
21Relation to FRBR
22================
23
24FRBR has conceptual model:
25
26  Work
27    - Expression (aka Edition)
28      - Manifestation
29        - Item
30
31Crudely our mapping is:
32
33PDW Work --> FRBR Work (+ some FRBR Expressions)
34PDW Item --> FRBR Manifestation (+ some FRBR expressions)
35
36  * Works have types.   
37  * We allow works to refer to other works.
38  * An item may have many works.
39
40Examples of how this works:
41---------------------------
42
430. Catalogues (from e.g. a Library or Amazon)
44
45All catalogues list Items in our system (in FRBR terms one could see them as
46Manifestation or Editions).
47
481. Book: "Forsyte Saga (vol 1)" by Galsworthy
49
50work: Forsyte Saga (vol 1)
51expression: Forsyte Saga X Edition
52manifestation/item: Penguin book isbn XXXXX published ...
53
54In our system we we\'d have a single work for the Forsyte saga. A new edition would
55not be registered as a new work *unless* it represented such a major change as
56justifying a new copyright (for example a translation would fall into this
57category).
58
59The Manifestation/Item becomes an Item in our system.
60
61
622. Recording: von Karajan conducting Beethoven Symphony No. 1 in A minor
63
64work: (composition) Beethoven Symphony in A minor
65work: (recording/performance): von Karajan recording
66 
67manifestation (release)
68  * may contain many recordings
69  * has tracks etc
70
71In our system:
72  * Work 1: Beethoven Symphony
73  * Work 2: von Karajan recording
74  * Item: The release
75
76
77Notes on FlexiDate implementation
78=================================
79
80Support for having arbitrary dates but which can be sorted properly and have an
81associated machine-readable version.
82
83For any given date attributed \'date\' end up with 3 cols:
84
85  * _date (represented on object as date)
86  * date_normed
87  * date_ordered
88'''
89import os
90import urllib
91
92# SQLAlchemy stuff
93from sqlalchemy import *
94from sqlalchemy import orm
95from sqlalchemy.orm.collections import attribute_mapped_collection
96from sqlalchemy.ext.associationproxy import association_proxy
97import swiss
98
99from types import *
100from meta import metadata, Session
101import pdw.name
102import pdw.pd
103from base import DomainObject, add_flexidate
104
105mapper = Session.mapper
106
107PD_KEY = 'pd.pdw.default'
108
109# Enumerations
110class ROLES:
111    author = u'author'
112    editor = u'editor'
113    performer = u'performer'
114
115class WORK_TYPES:
116    text = u'text'
117    composition = u'composition'
118    recording = u'recording'
119    photograph = u'photograph'
120    video = u'video'
121    database = u'database'
122
123class ENTITY_TYPES:
124    person = u'person'
125    organization = u'organization'
126    unknown = u'unknown'
127
128person_table = Table('person', metadata,
129    Column('id', UuidType, primary_key=True, default=UuidType.default),
130    Column('srcid', UnicodeText),
131    Column('name', UnicodeText, index=True),
132    Column('aka', UnicodeText),
133    # Flexidate columns
134    Column('birth_date', types.UnicodeText), # user entered version
135    Column('birth_date_normed', types.UnicodeText), # iso 8601
136    Column('birth_date_ordered', types.Float), # ordered version see swiss.date
137    # Flexidate columns
138    Column('death_date', types.UnicodeText), # user entered version
139    Column('death_date_normed', types.UnicodeText), # iso 8601
140    Column('death_date_ordered', types.Float), # ordered version see swiss.date
141    Column('entity_type', Unicode(255), default=ENTITY_TYPES.person),
142    Column('notes', UnicodeText),
143)
144
145work_table = Table('work', metadata,
146    Column('id', UuidType, primary_key=True, default=UuidType.default),
147    Column('srcid', UnicodeText),
148    Column('title', UnicodeText),
149    Column('type', Unicode(100), default=WORK_TYPES.text),
150    # Flexidate columns
151    Column('date', types.UnicodeText), # user entered version
152    Column('date_normed', types.UnicodeText), # iso 8601
153    Column('date_ordered', types.Float), # ordered version see swiss.date
154    Column('notes', UnicodeText),
155)
156
157item_table = Table('item', metadata,
158    Column('id', UuidType, primary_key=True, default=UuidType.default),
159    Column('srcid', UnicodeText),
160    Column('title', UnicodeText),
161    # Flexidate columns
162    Column('date', types.UnicodeText), # user entered version
163    Column('date_normed', types.UnicodeText), # iso 8601
164    Column('date_ordered', types.Float), # ordered version see swiss.date
165    Column('type', Unicode(100), default=WORK_TYPES.text),
166    Column('notes', UnicodeText),
167)
168
169extra_table = Table('extra', metadata,
170    Column('id', Integer, primary_key=True),
171    # name of table
172    Column('table', Unicode(100)),
173    # must always have UUIDs as pks ...
174    Column('fkid', UuidType, index=True),
175    Column('key', UnicodeText),
176    Column('value', JsonType),
177)
178
179work_2_item = Table('work_2_item', metadata,
180    Column('work_id', UuidType, ForeignKey('work.id'), primary_key=True,
181        index=True),
182    Column('item_id', UuidType, ForeignKey('item.id'), primary_key=True,
183        index=True),
184)
185
186work_2_person = Table('work_2_person', metadata,
187    Column('work_id', UuidType, ForeignKey('work.id'), primary_key=True,
188        index=True),
189    Column('person_id', UuidType, ForeignKey('person.id'), primary_key=True,
190        index=True),
191    Column('role', UnicodeText, default=ROLES.author)
192)
193
194item_2_person = Table('item_2_person', metadata,
195    Column('item_id', UuidType, ForeignKey('item.id'), primary_key=True,
196        index=True),
197    Column('person_id', UuidType, ForeignKey('person.id'), primary_key=True,
198        index=True),
199    Column('role', UnicodeText, default=ROLES.author)
200)
201
202
203def _create_person_work(work, role=ROLES.author):
204    return WorkPerson(work=work, role=role)
205
206class Person(DomainObject):
207    items = association_proxy('item_persons', 'item',
208        #creator=_create_item_person
209        )
210    works = association_proxy('work_persons', 'work',
211        # creator=_create_person_work
212        )
213
214    @classmethod
215    def by_name(self, name, create=True):
216        '''Return first Person with name `name`, or create if does not
217        exist and `create` is True.
218        '''
219        out = self.query.filter_by(name=name).first()
220        if out:
221            return out
222        elif create:
223            return Person(name=name)
224        else:
225            return None
226
227    def _readable_id(self):
228        name_str = pdw.name.normalize(self.name, parser_class=pdw.name.FirstLast).strip()
229        name_str = name_str.encode('utf8') # needs to be utf8 not unicode for the urllib.quote
230        name_str = name_str.replace(' ', '_')
231        encoded_name = urllib.quote(name_str)
232       
233        return encoded_name
234    readable_id = property(_readable_id)
235
236    def _url(self):
237      from routes import url_for
238      return url_for(controller='person', action='read', compressed_id=swiss.compress_uuid(self.id), readable_id=self.readable_id)
239    url = property(_url)
240
241    def _pd(self):
242      pd = self.extras.get(PD_KEY)
243      if pd is None:
244        # TODO this is a hack at the mo
245        if self.death_date_ordered:
246          if self.death_date_ordered < 1938:
247            pd = 1.0
248          else:
249            pd = 0.0
250        else:
251          pd = 0.1
252#        pd = pdw.pd.determine_status(self, 'uk')
253        self.extras[PD_KEY] = pd
254      return pd
255    pd = property(_pd)
256
257def _decode_person_readable_id(readable_id):
258    name_str = urllib.unquote(readable_id)
259    name_str = name_str.replace('_', ' ')
260    name_str = name_str.decode('utf8')
261    decoded_name = pdw.name.normalize(name_str, parser_class=pdw.name.LastFirst).strip()
262    return decoded_name
263
264def get_persons_matching_readable_id_query(readable_id):
265    name = _decode_person_readable_id(readable_id)
266    artist_query = Person.query.filter_by(name=name)
267    return artist_query
268
269
270
271def _create_work_person(person, role=ROLES.author):
272    return WorkPerson(person=person, role=role)
273
274class Work(DomainObject):
275    persons = association_proxy('work_persons', 'person', creator=_create_work_person)
276
277    def __unicode__(self):
278        out = super(Work, self).__unicode__()
279        out += u' person=%s' % [ a.__unicode__() for a in self.persons ]
280        return out
281   
282    @classmethod
283    def from_dict(self, in_dict):
284        # must convert persons first as o/w will attempt to set work.persons
285        # with dict (which won't work!)
286        local_dict = in_dict.copy() # we create a copy of the data, to keep the
287                                    # submitted original
288        persons = []
289        for person_dict in local_dict.get('persons', []):
290            person = Person.from_dict(person_dict)
291            persons.append(person)
292        if 'persons' in local_dict:
293            del local_dict['persons']
294        items = []
295        for item_dict in local_dict.get('items',[]):
296            item = Item.from_dict(item_dict)
297        if 'items' in local_dict:
298            del local_dict['persons']
299
300        out_obj = super(Work, self).from_dict(local_dict)
301        out_obj.persons = persons
302        out_obj.items = items
303        return out_obj
304
305    def to_dict(self):
306        '''
307        create a dict equivalent to the dict the api is receiving
308        '''
309        out_dict = {}
310        table = orm.class_mapper(self.__class__).mapped_table
311        # change ids into references? (e.g. license_id to license ...)
312        for col in table.c:
313            val = getattr(self, col.name)
314            out_dict[col.name] = val
315            # TODO: check type ...
316        persons = []
317        if self.persons:
318            for person in self.persons:
319                #TO DO: change this for a consult to the person table, to get
320                #the attrs the person has
321                oneperson = {'name': person.name,
322                             'birth_date': person.birth_date,
323                             'death_date': person.death_date,
324                             'type': person.entity_type,
325                             }
326                persons.append(oneperson)
327            out_dict['persons'] = persons
328        return out_dict
329
330
331    def _readable_id(self):
332        if self.persons:
333            name = self.persons[0].name # assume first person is creator for now
334            name_str = pdw.name.normalize(name, parser_class=pdw.name.FirstLast).strip()
335        else:
336            name_str = ""
337        encoded_name = ('%s--%s' % (self.title, name_str)).replace(' ', '_')
338       
339        return encoded_name
340    readable_id = property(_readable_id)
341
342    def _url(self):
343      from routes import url_for
344      return url_for(controller='work', action='read', compressed_id=swiss.compress_uuid(self.id), readable_id=self.readable_id)
345    url = property(_url)
346   
347    def _pd(self):
348      pd = self.extras.get(PD_KEY)
349      if pd is None:
350        pd_calc_parcel = pdw.pd.determine_status(self, 'uk')
351        if pd_calc_parcel.uncertainty == 0.0:
352          pd = pd_calc_parcel.pd_prob
353        else:
354          pd = 0.1
355        self.extras[PD_KEY] = pd
356      return pd
357    pd = property(_pd)
358
359def _decode_work_readable_id(readable_id):
360    decoded_title, decoded_name = "", ""
361    readable_id_sections = readable_id.split("--")
362    if len(readable_id_sections) == 2:
363        for i in range(len(readable_id_sections)):
364            readable_id_sections[i] = readable_id_sections[i].replace('_', ' ')
365        decoded_title, name_str = readable_id_sections
366        decoded_name = pdw.name.normalize(name_str, parser_class=pdw.name.LastFirst).strip()
367    return decoded_title, decoded_name
368
369def get_works_matching_readable_id_query(readable_id):
370    title, name = _decode_work_readable_id(readable_id)
371    query = Work.query.filter(and_(Work.title==title, Person.name==name))
372    return query.all()
373
374
375def _create_item_person(person, role=ROLES.author):
376    return ItemPerson(person=person, role=role)
377
378class Item(DomainObject):
379    def __unicode__(self):
380        out = super(Item, self).__unicode__()
381        out += u' person=%s' % [ a.__unicode__() for a in self.persons ]
382        return out
383    persons = association_proxy('item_persons', 'person', creator=_create_item_person)
384
385    def _readable_id(self):
386        if self.persons:
387            name = self.persons[0].name # assume first person is creator for now
388            name_str = pdw.name.normalize(name, parser_class=pdw.name.FirstLast).strip()
389        else:
390            name_str = ""
391        encoded_name = ('%s--%s' % (self.title, name_str)).replace(' ', '_')
392       
393        return encoded_name
394    readable_id = property(_readable_id)
395
396    def _url(self):
397      from routes import url_for
398      return url_for(controller='item', action='read', compressed_id=swiss.compress_uuid(self.id), readable_id=self.readable_id)
399    url = property(_url)
400
401def _decode_item_readable_id(readable_id):
402    decoded_title, decoded_name = "", ""
403    readable_id_sections = readable_id.split("--")
404    if len(readable_id_sections) == 2:
405        for i in range(len(readable_id_sections)):
406            readable_id_sections[i] = readable_id_sections[i].replace('_', ' ')
407        decoded_title, name_str = readable_id_sections
408        decoded_name = pdw.name.normalize(name_str, parser_class=pdw.name.LastFirst).strip()
409    return decoded_title, decoded_name
410
411def get_items_matching_readable_id_query(readable_id):
412    title, name = _decode_item_readable_id(readable_id)
413    query = Item.query.filter(and_(Item.title==title, Person.name==name))
414    return query
415
416class WorkItem(DomainObject):
417    pass
418
419class WorkPerson(DomainObject):
420    def __init__(self, work=None, person=None, role=None):
421        self.work = work
422        self.person = person
423        self.role = role
424
425
426class ItemPerson(DomainObject):
427    def __init__(self, item=None, person=None, role=None):
428        self.item = item
429        self.person = person
430        self.role = role
431
432class Extra(DomainObject):
433    pass
434
435def extraable(cls, name='_extras', usedict=True):
436    mapper = orm.class_mapper(cls)
437    table = mapper.local_table
438    table_name = unicode(table.name)
439    primaryjoin = and_(
440        extra_table.c.table == table_name,
441        extra_table.c.fkid == list(table.primary_key)[0]
442        )
443    foreign_keys = [extra_table.c.fkid]
444    from sqlalchemy.orm.collections import attribute_mapped_collection
445    mapper.add_property(name, orm.relation(
446        Extra,
447        primaryjoin=primaryjoin,
448        foreign_keys=foreign_keys,
449        collection_class=attribute_mapped_collection('key'),
450        # backref
451        )
452    )
453    from sqlalchemy.ext.associationproxy import association_proxy
454    def _create_extra(key, value):
455        return Extra(table=table_name, key=unicode(key), value=value)
456    cls.extras = association_proxy('_extras', 'value',
457                creator=_create_extra)
458
459
460add_flexidate(Person, 'birth_date')
461add_flexidate(Person, 'death_date')
462add_flexidate(Work, 'date')
463add_flexidate(Item, 'date')
464
465## Custom Property for FlexiDate
466
467# TODO: not yet usable properly as require SQLAlchemy >= 0.5
468from sqlalchemy import sql
469class FlexiDateComparator(orm.PropComparator):
470
471    def _get_ordered(self, other):
472        ordered_name = self.prop.name + '_ordered'
473        col = self.prop.table.c[ordered_name]
474        other = swiss.date.parse(other).as_float()
475        return (col, other)
476
477    def __gt__(self, other):
478        """define the 'greater than' operation"""
479        col, val = self._get_ordered(other)
480        return col < val
481
482    def __eq__(self, other):
483        col, val = self._get_ordered(other)
484        return col == val
485
486mapper(Work, work_table, properties={
487    # should use FlexiDateComparator here
488    # but not supported in synonyms until SQLAlchemy 0.5
489    # comparator_factory=FlexiDateComparator
490    'date':orm.synonym('_date', map_column=True),
491    'items':orm.relation(Item, secondary=work_2_item, backref='works'),
492    },
493    order_by=work_table.c.title,
494)
495mapper(Item, item_table, properties={
496    # comparator_factory=FlexiDateComparator
497    'date':orm.synonym('_date', map_column=True),
498    },
499    order_by=item_table.c.title,
500)
501mapper(Person, person_table, properties={
502    # comparator_factory=FlexiDateComparator
503    'birth_date':orm.synonym('_birth_date', map_column=True),
504    'death_date':orm.synonym('_death_date', map_column=True),
505    },
506    order_by=person_table.c.name,
507    )
508mapper(WorkPerson, work_2_person, properties={
509    'work': orm.relation(Work,
510        backref=orm.backref('work_persons',
511            cascade='all, delete, delete-orphan',
512            ),
513        # pointless ...
514        order_by=work_table.c.title,
515        ),
516    'person': orm.relation(Person,
517        backref=orm.backref('work_persons',
518            cascade='all, delete, delete-orphan',
519            ),
520        # pointless ...
521        order_by=person_table.c.name,
522        ),
523    },
524    order_by=work_2_person.c.work_id,
525    )
526mapper(ItemPerson, item_2_person, properties={
527    'item': orm.relation(Item,
528        backref=orm.backref('item_persons',
529            cascade='all, delete, delete-orphan'),
530        ),
531    'person': orm.relation(Person,
532        backref=orm.backref('item_persons',
533            cascade='all, delete, delete-orphan'),
534        ),
535    },
536    order_by=item_2_person.c.item_id,
537)
538mapper(WorkItem, work_2_item)
539mapper(Extra, extra_table)
540extraable(Work)
541extraable(Item)
542extraable(Person)
Note: See TracBrowser for help on using the browser.