root/pdw/model/frbr.py @ 325:61a8069c7b94

Revision 325:61a8069c7b94, 17.3 KB (checked in by acracia, 6 months ago)

changes to the api page, not taking the input and processing it with the calculators.
several other changes added to the model to process from dicts and to dicts

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    def to_dict(self):
305        out_dict = {}
306        table = orm.class_mapper(self.__class__).mapped_table
307        # change ids into references? (e.g. license_id to license ...)
308        for col in table.c:
309            val = getattr(self, col.name)
310            out_dict[col.name] = val
311            # TODO: check type ...
312        if self.persons:
313            persons = []
314            for person in self.persons:
315                oneperson = {'name': person.name,
316                             'birth_date': person.birth_date,
317                             'death_date': person.death_date,
318                             'type': person.type,
319                             'country': person.country,}
320                persons.append(oneperson)
321            out_dict['persons'] = persons
322
323
324        return out_dict
325
326
327
328
329    def _readable_id(self):
330        if self.persons:
331            name = self.persons[0].name # assume first person is creator for now
332            name_str = pdw.name.normalize(name, parser_class=pdw.name.FirstLast).strip()
333        else:
334            name_str = ""
335        encoded_name = ('%s--%s' % (self.title, name_str)).replace(' ', '_')
336       
337        return encoded_name
338    readable_id = property(_readable_id)
339
340    def _url(self):
341      from routes import url_for
342      return url_for(controller='work', action='read', compressed_id=swiss.compress_uuid(self.id), readable_id=self.readable_id)
343    url = property(_url)
344   
345    def _pd(self):
346      pd = self.extras.get(PD_KEY)
347      if pd is None:
348        pd_calc_parcel = pdw.pd.determine_status(self, 'uk')
349        if pd_calc_parcel.uncertainty == 0.0:
350          pd = pd_calc_parcel.pd_prob
351        else:
352          pd = 0.1
353        self.extras[PD_KEY] = pd
354      return pd
355    pd = property(_pd)
356
357def _decode_work_readable_id(readable_id):
358    decoded_title, decoded_name = "", ""
359    readable_id_sections = readable_id.split("--")
360    if len(readable_id_sections) == 2:
361        for i in range(len(readable_id_sections)):
362            readable_id_sections[i] = readable_id_sections[i].replace('_', ' ')
363        decoded_title, name_str = readable_id_sections
364        decoded_name = pdw.name.normalize(name_str, parser_class=pdw.name.LastFirst).strip()
365    return decoded_title, decoded_name
366
367def get_works_matching_readable_id_query(readable_id):
368    title, name = _decode_work_readable_id(readable_id)
369    query = Work.query.filter(and_(Work.title==title, Person.name==name))
370    return query.all()
371
372
373def _create_item_person(person, role=ROLES.author):
374    return ItemPerson(person=person, role=role)
375
376class Item(DomainObject):
377    def __unicode__(self):
378        out = super(Item, self).__unicode__()
379        out += u' person=%s' % [ a.__unicode__() for a in self.persons ]
380        return out
381    persons = association_proxy('item_persons', 'person', creator=_create_item_person)
382
383    def _readable_id(self):
384        if self.persons:
385            name = self.persons[0].name # assume first person is creator for now
386            name_str = pdw.name.normalize(name, parser_class=pdw.name.FirstLast).strip()
387        else:
388            name_str = ""
389        encoded_name = ('%s--%s' % (self.title, name_str)).replace(' ', '_')
390       
391        return encoded_name
392    readable_id = property(_readable_id)
393
394    def _url(self):
395      from routes import url_for
396      return url_for(controller='item', action='read', compressed_id=swiss.compress_uuid(self.id), readable_id=self.readable_id)
397    url = property(_url)
398
399def _decode_item_readable_id(readable_id):
400    decoded_title, decoded_name = "", ""
401    readable_id_sections = readable_id.split("--")
402    if len(readable_id_sections) == 2:
403        for i in range(len(readable_id_sections)):
404            readable_id_sections[i] = readable_id_sections[i].replace('_', ' ')
405        decoded_title, name_str = readable_id_sections
406        decoded_name = pdw.name.normalize(name_str, parser_class=pdw.name.LastFirst).strip()
407    return decoded_title, decoded_name
408
409def get_items_matching_readable_id_query(readable_id):
410    title, name = _decode_item_readable_id(readable_id)
411    query = Item.query.filter(and_(Item.title==title, Person.name==name))
412    return query
413
414class WorkItem(DomainObject):
415    pass
416
417class WorkPerson(DomainObject):
418    def __init__(self, work=None, person=None, role=None):
419        self.work = work
420        self.person = person
421        self.role = role
422
423
424class ItemPerson(DomainObject):
425    def __init__(self, item=None, person=None, role=None):
426        self.item = item
427        self.person = person
428        self.role = role
429
430class Extra(DomainObject):
431    pass
432
433def extraable(cls, name='_extras', usedict=True):
434    mapper = orm.class_mapper(cls)
435    table = mapper.local_table
436    table_name = unicode(table.name)
437    primaryjoin = and_(
438        extra_table.c.table == table_name,
439        extra_table.c.fkid == list(table.primary_key)[0]
440        )
441    foreign_keys = [extra_table.c.fkid]
442    from sqlalchemy.orm.collections import attribute_mapped_collection
443    mapper.add_property(name, orm.relation(
444        Extra,
445        primaryjoin=primaryjoin,
446        foreign_keys=foreign_keys,
447        collection_class=attribute_mapped_collection('key'),
448        # backref
449        )
450    )
451    from sqlalchemy.ext.associationproxy import association_proxy
452    def _create_extra(key, value):
453        return Extra(table=table_name, key=unicode(key), value=value)
454    cls.extras = association_proxy('_extras', 'value',
455                creator=_create_extra)
456
457
458add_flexidate(Person, 'birth_date')
459add_flexidate(Person, 'death_date')
460add_flexidate(Work, 'date')
461add_flexidate(Item, 'date')
462
463## Custom Property for FlexiDate
464
465# TODO: not yet usable properly as require SQLAlchemy >= 0.5
466from sqlalchemy import sql
467class FlexiDateComparator(orm.PropComparator):
468
469    def _get_ordered(self, other):
470        ordered_name = self.prop.name + '_ordered'
471        col = self.prop.table.c[ordered_name]
472        other = swiss.date.parse(other).as_float()
473        return (col, other)
474
475    def __gt__(self, other):
476        """define the 'greater than' operation"""
477        col, val = self._get_ordered(other)
478        return col < val
479
480    def __eq__(self, other):
481        col, val = self._get_ordered(other)
482        return col == val
483
484mapper(Work, work_table, properties={
485    # should use FlexiDateComparator here
486    # but not supported in synonyms until SQLAlchemy 0.5
487    # comparator_factory=FlexiDateComparator
488    'date':orm.synonym('_date', map_column=True),
489    'items':orm.relation(Item, secondary=work_2_item, backref='works'),
490    },
491    order_by=work_table.c.title,
492)
493mapper(Item, item_table, properties={
494    # comparator_factory=FlexiDateComparator
495    'date':orm.synonym('_date', map_column=True),
496    },
497    order_by=item_table.c.title,
498)
499mapper(Person, person_table, properties={
500    # comparator_factory=FlexiDateComparator
501    'birth_date':orm.synonym('_birth_date', map_column=True),
502    'death_date':orm.synonym('_death_date', map_column=True),
503    },
504    order_by=person_table.c.name,
505    )
506mapper(WorkPerson, work_2_person, properties={
507    'work': orm.relation(Work,
508        backref=orm.backref('work_persons',
509            cascade='all, delete, delete-orphan',
510            ),
511        # pointless ...
512        order_by=work_table.c.title,
513        ),
514    'person': orm.relation(Person,
515        backref=orm.backref('work_persons',
516            cascade='all, delete, delete-orphan',
517            ),
518        # pointless ...
519        order_by=person_table.c.name,
520        ),
521    },
522    order_by=work_2_person.c.work_id,
523    )
524mapper(ItemPerson, item_2_person, properties={
525    'item': orm.relation(Item,
526        backref=orm.backref('item_persons',
527            cascade='all, delete, delete-orphan'),
528        ),
529    'person': orm.relation(Person,
530        backref=orm.backref('item_persons',
531            cascade='all, delete, delete-orphan'),
532        ),
533    },
534    order_by=item_2_person.c.item_id,
535)
536mapper(WorkItem, work_2_item)
537mapper(Extra, extra_table)
538extraable(Work)
539extraable(Item)
540extraable(Person)
Note: See TracBrowser for help on using the browser.