#!/usr/bin/env python # -*- coding: utf-8 -*- # Copyright (C) 2012-2017 Étienne Loks # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # See the file COPYING for details. import datetime from django.conf import settings from django.contrib.gis.db import models from django.core.urlresolvers import reverse from django.db import connection from django.db.models import Q from django.db.models.signals import post_delete, post_save, m2m_changed from django.utils.translation import ugettext_lazy as _, pgettext, \ activate, pgettext_lazy, deactivate from django.utils.text import slugify from ishtar_common.utils import cached_label_changed from ishtar_common.models import Document, GeneralType, \ BaseHistorizedItem, HistoricalRecords, OwnPerms, ShortMenuItem, \ GeneralRelationType, GeneralRecordRelations, post_delete_record_relation,\ post_save_cache, ValueGetter, BulkUpdatedItem, ExternalIdManager, \ RelationItem, Town, get_current_profile, document_attached_changed from archaeological_operations.models import Operation, Period, Parcel, \ ArchaeologicalSite class DatingType(GeneralType): class Meta: verbose_name = _(u"Dating type") verbose_name_plural = _(u"Dating types") ordering = ('label',) post_save.connect(post_save_cache, sender=DatingType) post_delete.connect(post_save_cache, sender=DatingType) class DatingQuality(GeneralType): class Meta: verbose_name = _(u"Dating quality type") verbose_name_plural = _(u"Dating quality types") ordering = ('label',) post_save.connect(post_save_cache, sender=DatingQuality) post_delete.connect(post_save_cache, sender=DatingQuality) class Dating(models.Model): period = models.ForeignKey(Period, verbose_name=_(u"Period")) start_date = models.IntegerField(_(u"Start date"), blank=True, null=True) end_date = models.IntegerField(_(u"End date"), blank=True, null=True) dating_type = models.ForeignKey(DatingType, verbose_name=_(u"Dating type"), blank=True, null=True) quality = models.ForeignKey(DatingQuality, verbose_name=_(u"Quality"), blank=True, null=True) precise_dating = models.TextField(_(u"Precise dating"), blank=True, null=True) class Meta: verbose_name = _(u"Dating") verbose_name_plural = _(u"Datings") def __unicode__(self): start_date = self.start_date and unicode(self.start_date) or u"" end_date = self.end_date and unicode(self.end_date) or u"" if not start_date and not end_date: return unicode(self.period) return u"%s (%s-%s)" % (self.period, start_date, end_date) HISTORY_ATTR = ["period", "start_date", "end_date", "dating_type", "quality", "precise_dating"] def history_compress(self): values = {} for attr in self.HISTORY_ATTR: val = getattr(self, attr) if hasattr(val, 'history_compress'): val = val.history_compress() elif hasattr(val, 'isoformat'): val = val.isoformat() elif val is None: val = '' else: val = unicode(val) values[attr] = val return values @classmethod def history_decompress(cls, full_value, create=False): if not full_value: return [] full_res = [] for value in full_value: res = {} for key in value: val = value[key] if val == '': val = None elif key in ("period", "dating_type", "quality"): field = cls._meta.get_field(key) q = field.related_model.objects.filter(txt_idx=val) if q.count(): val = q.all()[0] else: # do not exist anymore in db val = None elif key in ("start_date", "end_date"): val = int(val) res[key] = val if create: res = cls.objects.create(**res) full_res.append(res) return full_res @classmethod def is_identical(cls, dating_1, dating_2): """ Compare two dating attribute by attribute and return True if all attribute is identical """ for attr in ["period", "start_date", "end_date", "dating_type", "quality", "precise_dating"]: value1 = getattr(dating_1, attr) value2 = getattr(dating_2, attr) if attr == "precise_dating": value1 = value1.strip() value2 = value2.strip() if value1 != value2: return False return True def context_records_lbl(self): return u" - ".join( [cr.cached_label for cr in self.context_records.all()] ) context_records_lbl.short_description = _(u"Context record") context_records_lbl.admin_order_field = "context_records__cached_label" def finds_lbl(self): return u" - ".join( [f.cached_label for f in self.find.all()] ) finds_lbl.short_description = _(u"Find") finds_lbl.admin_order_field = "find__cached_label" @classmethod def fix_dating_association(cls, obj): """ Fix redundant m2m dating association (usually after imports) """ current_datings = [] for dating in obj.datings.order_by('pk').all(): key = (dating.period.pk, dating.start_date, dating.end_date, dating.dating_type, dating.quality, dating.precise_dating) if key not in current_datings: current_datings.append(key) continue dating.delete() class Unit(GeneralType): order = models.IntegerField(_(u"Order")) parent = models.ForeignKey( "Unit", verbose_name=_(u"Parent context record type"), blank=True, null=True) class Meta: verbose_name = _(u"Context record Type") verbose_name_plural = _(u"Context record Types") ordering = ('order', 'label') def __unicode__(self): return self.label post_save.connect(post_save_cache, sender=Unit) post_delete.connect(post_save_cache, sender=Unit) class ActivityType(GeneralType): order = models.IntegerField(_(u"Order")) class Meta: verbose_name = _(u"Activity Type") verbose_name_plural = _(u"Activity Types") ordering = ('order',) def __unicode__(self): return self.label post_save.connect(post_save_cache, sender=ActivityType) post_delete.connect(post_save_cache, sender=ActivityType) class IdentificationType(GeneralType): order = models.IntegerField(_(u"Order")) class Meta: verbose_name = _(u"Identification Type") verbose_name_plural = _(u"Identification Types") ordering = ('order', 'label') def __unicode__(self): return self.label post_save.connect(post_save_cache, sender=IdentificationType) post_delete.connect(post_save_cache, sender=IdentificationType) class ExcavationTechnicType(GeneralType): class Meta: verbose_name = _(u"Excavation technique type") verbose_name_plural = _(u"Excavation technique types") ordering = ('label',) post_save.connect(post_save_cache, sender=ExcavationTechnicType) post_delete.connect(post_save_cache, sender=ExcavationTechnicType) class DocumentationType(GeneralType): class Meta: verbose_name = _(u"Documentation type") verbose_name_plural = _(u"Documentation types") ordering = ('label',) post_save.connect(post_save_cache, sender=DocumentationType) post_delete.connect(post_save_cache, sender=DocumentationType) class CRBulkView(object): CREATE_SQL = """ CREATE VIEW context_records_cached_label_bulk_update AS ( SELECT cr.id AS id, ope.code_patriarche AS main_code, ope.year AS year, ope.operation_code AS ope_code, parcel.section AS section, parcel.parcel_number AS number, cr.label AS label FROM archaeological_context_records_contextrecord AS cr INNER JOIN archaeological_operations_operation ope ON ope.id = cr.operation_id INNER JOIN archaeological_operations_parcel parcel ON cr.parcel_id = parcel.id );""" DELETE_SQL = """ DROP VIEW context_records_cached_label_bulk_update; """ class ContextRecord(BulkUpdatedItem, BaseHistorizedItem, OwnPerms, ValueGetter, ShortMenuItem, RelationItem): SHOW_URL = 'show-contextrecord' SLUG = 'contextrecord' EXTERNAL_ID_KEY = 'context_record_external_id' EXTERNAL_ID_DEPENDENCIES = ['base_finds'] TABLE_COLS = ['label', 'operation__common_name', 'town__name', 'parcel__cached_label', 'unit'] if settings.COUNTRY == 'fr': TABLE_COLS.insert(1, 'operation__code_patriarche') TABLE_COLS_FOR_OPE = ['label', 'parcel', 'unit', 'datings__period__label', 'description'] COL_LABELS = { 'datings__period__label': _(u"Periods"), 'datings__period': _(u"Datings (period)"), 'detailled_related_context_records': _(u"Related context records"), 'operation__code_patriarche': _(u"Operation (Patriarche code)"), 'operation__common_name': _(u"Operation (name)"), 'parcel__external_id': _(u"Parcel (external ID)"), 'town__name': _(u"Town"), 'town': _(u"Town"), 'parcel__year': _(u"Parcel (year)"), 'section__parcel_number': _(u"Parcel"), 'parcel__cached_label': _(u"Parcel"), } CONTEXTUAL_TABLE_COLS = { 'full': { 'related_context_records': 'detailled_related_context_records' } } # search parameters EXTRA_REQUEST_KEYS = { 'town': 'town__pk', 'town__name': 'town__name', 'parcel__cached_label': 'parcel__cached_label', 'operation__year': 'operation__year__contains', 'year': 'operation__year__contains', 'operation__code_patriarche': 'operation__code_patriarche', 'operation__operation_code': 'operation__operation_code', 'operation__common_name': 'operation__common_name', 'datings__period': 'datings__period__pk', 'parcel_0': 'operation__parcels__section', 'parcel_1': 'operation__parcels__parcel_number', 'parcel_2': 'operation__parcels__public_domain', 'label': 'label__icontains', 'archaeological_sites': 'operation__archaeological_sites__pk', 'cached_label': 'cached_label__icontains', 'datings__period__label': 'datings__period__label', 'operation_id': 'operation_id', } RELATION_TYPES_PREFIX = {'ope_relation_types': 'operation__', 'cr_relation_types': ''} # alternative names of fields for searches ALT_NAMES = { 'label': ( pgettext_lazy("key for text search", u"id"), 'label__iexact' ), 'town': ( pgettext_lazy("key for text search", u"town"), 'town__cached_label__iexact' ), 'operation__year': ( pgettext_lazy("key for text search", u"operation-year"), 'operation__year' ), 'operation__code_patriarche': ( pgettext_lazy("key for text search", u"patriarche"), 'operation__code_patriarche__iexact' ), 'operation__operation_code': ( pgettext_lazy("key for text search", u"operation-code"), 'operation__operation_code' ), 'operation__cached_label': ( pgettext_lazy("key for text search", u"operation"), 'operation__cached_label__icontains' ), 'archaeological_site': ( pgettext_lazy("key for text search", u"site"), 'archaeological_site__cached_label__icontains' ), 'ope_relation_types': ( pgettext_lazy("key for text search", u"operation-relation-type"), 'ope_relation_types' ), 'datings__period': ( pgettext_lazy("key for text search", u"period"), 'datings__period__label__iexact' ), 'unit': ( pgettext_lazy("key for text search", u"unit-type"), 'unit__label__iexact' ), 'parcel': ( pgettext_lazy("key for text search", u"parcel"), 'parcel__cached_label__iexact' ), 'cr_relation_types': ( pgettext_lazy("key for text search", u"record-relation-type"), 'cr_relation_types' ), } for v in ALT_NAMES.values(): for language_code, language_lbl in settings.LANGUAGES: activate(language_code) EXTRA_REQUEST_KEYS[unicode(v[0])] = v[1] deactivate() PARENT_ONLY_SEARCH_VECTORS = ['operation', "archaeological_site"] BASE_SEARCH_VECTORS = ["cached_label", "label", "location", "town__name", "interpretation", "filling", "datings_comment", "identification__label", "activity__label", "excavation_technic__label"] M2M_SEARCH_VECTORS = ["datings__period__label"] UP_MODEL_QUERY = { "operation": ( pgettext_lazy("key for text search", u"operation"), 'cached_label'), "site": ( pgettext_lazy("key for text search", u"site"), 'cached_label'), } RELATIVE_SESSION_NAMES = [ ('operation', 'operation__pk'), ('site', 'archaeological_site__pk'), ('file', 'operation__associated_file__pk'), ] history = HistoricalRecords() objects = ExternalIdManager() # fields external_id = models.TextField(_(u"External ID"), blank=True, null=True) auto_external_id = models.BooleanField( _(u"External ID is set automatically"), default=False) parcel = models.ForeignKey( Parcel, verbose_name=_(u"Parcel"), related_name='context_record', blank=True, null=True) town = models.ForeignKey( Town, verbose_name=_(u"Town"), related_name='context_record', blank=True, null=True) operation = models.ForeignKey(Operation, verbose_name=_(u"Operation"), related_name='context_record') archaeological_site = models.ForeignKey( ArchaeologicalSite, verbose_name=_(u"Archaeological site"), blank=True, null=True, related_name='context_records') label = models.CharField(_(u"ID"), max_length=200) description = models.TextField(_(u"Description"), blank=True, null=True) comment = models.TextField(_(u"General comment"), blank=True, null=True) opening_date = models.DateField(_(u"Opening date"), blank=True, null=True) closing_date = models.DateField(_(u"Closing date"), blank=True, null=True) length = models.FloatField(_(u"Length (m)"), blank=True, null=True) width = models.FloatField(_(u"Width (m)"), blank=True, null=True) thickness = models.FloatField(_(u"Thickness (m)"), blank=True, null=True) diameter = models.FloatField(_(u"Diameter (m)"), blank=True, null=True) depth = models.FloatField(_(u"Depth (m)"), blank=True, null=True) depth_of_appearance = models.FloatField( _(u"Depth of appearance (m)"), blank=True, null=True) location = models.TextField( _(u"Location"), blank=True, null=True, help_text=_(u"A short description of the location of the context " u"record")) datings = models.ManyToManyField(Dating, related_name='context_records') documentations = models.ManyToManyField(DocumentationType, blank=True) datings_comment = models.TextField(_(u"Comment on datings"), blank=True, null=True) unit = models.ForeignKey(Unit, verbose_name=_(u"Context record type"), related_name='+', blank=True, null=True) filling = models.TextField(_(u"Filling"), blank=True, null=True) interpretation = models.TextField(_(u"Interpretation"), blank=True, null=True) taq = models.IntegerField( _(u"TAQ"), blank=True, null=True, help_text=_(u"\"Terminus Ante Quem\" the context record can't have " u"been created after this date")) taq_estimated = models.IntegerField( _(u"Estimated TAQ"), blank=True, null=True, help_text=_(u"Estimation of a \"Terminus Ante Quem\"")) tpq = models.IntegerField( _(u"TPQ"), blank=True, null=True, help_text=_(u"\"Terminus Post Quem\" the context record can't have " u"been created before this date")) tpq_estimated = models.IntegerField( _(u"Estimated TPQ"), blank=True, null=True, help_text=_(u"Estimation of a \"Terminus Post Quem\"")) identification = models.ForeignKey( IdentificationType, blank=True, null=True, verbose_name=_(u"Identification"),) activity = models.ForeignKey(ActivityType, blank=True, null=True, verbose_name=_(u"Activity"),) excavation_technic = models.ForeignKey( ExcavationTechnicType, blank=True, null=True, verbose_name=_(u"Excavation technique")) related_context_records = models.ManyToManyField( 'ContextRecord', through='RecordRelations', blank=True) point_2d = models.PointField(_(u"Point (2D)"), blank=True, null=True) point = models.PointField(_(u"Point (3D)"), blank=True, null=True, dim=3) multi_polygon = models.MultiPolygonField(_(u"Multi polygon"), blank=True, null=True) documents = models.ManyToManyField( Document, related_name='context_records', verbose_name=_(u"Documents"), blank=True) main_image = models.ForeignKey( Document, related_name='main_image_context_records', verbose_name=_(u"Main image"), blank=True, null=True) cached_label = models.TextField(_(u"Cached name"), null=True, blank=True, db_index=True) class Meta: verbose_name = _(u"Context Record") verbose_name_plural = _(u"Context Record") permissions = ( ("view_contextrecord", u"Can view all Context Records"), ("view_own_contextrecord", u"Can view own Context Record"), ("add_own_contextrecord", u"Can add own Context Record"), ("change_own_contextrecord", u"Can change own Context Record"), ("delete_own_contextrecord", u"Can delete own Context Record"), ) ordering = ('cached_label',) def natural_key(self): return (self.external_id, ) @property def name(self): return self.label or "" @property def short_class_name(self): return pgettext("short", u"Context record") def __unicode__(self): return self.short_label @classmethod def cached_label_bulk_update(cls, operation_id=None, parcel_id=None, transaction_id=None): transaction_id, is_recursion = cls.bulk_recursion( transaction_id, [operation_id, parcel_id]) if is_recursion: return if operation_id: where = "operation_id = %s" args = [int(operation_id)] kwargs = {'operation_id': operation_id} elif parcel_id: where = "parcel_id = %s" args = [int(parcel_id)] kwargs = {'parcel_id': parcel_id} else: return kwargs['transaction_id'] = transaction_id profile = get_current_profile() sql = """ UPDATE "archaeological_context_records_contextrecord" AS cr SET cached_label = CASE WHEN context_records_cached_label_bulk_update.main_code IS NULL THEN CASE WHEN context_records_cached_label_bulk_update.year IS NOT NULL AND context_records_cached_label_bulk_update.ope_code IS NOT NULL THEN '{ope_prefix}' || context_records_cached_label_bulk_update.year || '-' || context_records_cached_label_bulk_update.ope_code ELSE '' END ELSE '{main_ope_prefix}' || context_records_cached_label_bulk_update.main_code END || '{join}' || context_records_cached_label_bulk_update.section || '{join}' || context_records_cached_label_bulk_update.number || '{join}' || context_records_cached_label_bulk_update.label FROM context_records_cached_label_bulk_update WHERE cr.id = context_records_cached_label_bulk_update.id AND cr.id IN ( SELECT id FROM archaeological_context_records_contextrecord WHERE {where} ); """.format(main_ope_prefix=profile.operation_prefix, ope_prefix=profile.default_operation_prefix, join=settings.JOINT, where=where) with connection.cursor() as c: c.execute(sql, args) cls._meta.get_field( 'base_finds').related_model.cached_label_bulk_update(**kwargs) @property def short_label(self): return settings.JOINT.join([unicode(item) for item in [ self.operation.get_reference(), self.parcel, self.label] if item]) @property def relation_label(self): return self.label @property def show_url(self): return reverse('show-contextrecord', args=[self.pk, '']) @classmethod def get_query_owns(cls, ishtaruser): q = cls._construct_query_own( 'operation__', Operation._get_query_owns_dicts(ishtaruser) ) | cls._construct_query_own( 'base_finds__find__basket__', [{"shared_with": ishtaruser, "shared_write_with": ishtaruser}] ) | cls._construct_query_own('', [ {'history_creator': ishtaruser.user_ptr}, {'operation__end_date__isnull': True} ]) return q @classmethod def get_owns(cls, user, menu_filtr=None, limit=None, values=None, get_short_menu_class=None): replace_query = None if menu_filtr and 'operation' in menu_filtr: replace_query = Q(operation=menu_filtr['operation']) owns = super(ContextRecord, cls).get_owns( user, replace_query=replace_query, limit=limit, values=values, get_short_menu_class=get_short_menu_class) return cls._return_get_owns(owns, values, get_short_menu_class) def full_label(self): return unicode(self) def _real_label(self): if not self.operation.code_patriarche: return return settings.JOINT.join((self.operation.code_patriarche, self.label)) def _temp_label(self): if self.operation.code_patriarche: return return settings.JOINT.join([unicode(lbl) for lbl in [ self.operation.year, self.operation.operation_code, self.label] if lbl]) def _generate_cached_label(self): return self.full_label() def _get_associated_cached_labels(self): from archaeological_finds.models import Find, BaseFind return list(Find.objects.filter(base_finds__context_record=self).all())\ + list(BaseFind.objects.filter(context_record=self).all()) def _cached_labels_bulk_update(self): self.base_finds.model.cached_label_bulk_update( context_record_id=self.pk) return True def _get_base_image_path(self): return self.operation._get_base_image_path() + \ u"/{}/{}".format(self.SLUG, slugify(self.label or u"00")) @property def archaeological_site_reference(self): if self.archaeological_site: return self.archaeological_site.reference if self.operation.archaeological_sites.count(): return u"-".join( [a.reference for a in self.operation.archaeological_sites.all()] ) return u"" @property def reference(self): if not self.operation: return "00" return self.full_label() def get_department(self): if not self.operation: return "00" return self.operation.get_department() def get_town_label(self): if not self.operation: return "00" return self.operation.get_town_label() @classmethod def get_periods(cls, slice='year', fltr={}): q = cls.objects if fltr: q = q.filter(**fltr) if slice == 'year': years = set() for res in list(q.values('operation__start_date')): if res['operation__start_date']: yr = res['operation__start_date'].year years.add(yr) return list(years) return [] @classmethod def get_by_year(cls, year, fltr={}): q = cls.objects if fltr: q = q.filter(**fltr) return q.filter(operation__start_date__year=year) @classmethod def get_operations(cls): return [dct['operation__pk'] for dct in cls.objects.values('operation__pk').distinct()] @classmethod def get_by_operation(cls, operation_id): return cls.objects.filter(operation__pk=operation_id) @classmethod def get_total_number(cls, fltr={}): q = cls.objects if fltr: q = q.filter(**fltr) return q.count() def detailled_related_context_records(self): crs = [] for cr in self.right_relations.all(): crs.append(u"{} ({})".format(cr.right_record, cr.relation_type.get_tiny_label())) return u" ; ".join(crs) def find_docs_q(self): return Document.objects.filter(finds__base_finds__context_record=self) def fix(self): """ Fix redundant m2m dating association (usually after imports) """ Dating.fix_dating_association(self) def save(self, *args, **kwargs): super(ContextRecord, self).save(*args, **kwargs) if (not self.town and self.parcel) or ( self.parcel and self.parcel.town != self.town): self.town = self.parcel.town self.skip_history_when_saving = True self.save() post_save.connect(cached_label_changed, sender=ContextRecord) m2m_changed.connect(document_attached_changed, sender=ContextRecord.documents.through) class RelationType(GeneralRelationType): class Meta: verbose_name = _(u"Relation type") verbose_name_plural = _(u"Relation types") ordering = ('order', 'label') class RecordRelations(GeneralRecordRelations, models.Model): MAIN_ATTR = 'left_record' left_record = models.ForeignKey(ContextRecord, related_name='right_relations') right_record = models.ForeignKey(ContextRecord, related_name='left_relations') relation_type = models.ForeignKey(RelationType) TABLE_COLS = [ "left_record__label", "left_record__unit", "left_record__parcel", "relation_type", "right_record__label", "right_record__unit", "right_record__parcel", ] COL_LABELS = { "left_record__label": _(u"ID (left)"), "left_record__unit": _(u"Context record type (left)"), "left_record__parcel": _(u"Parcel (left)"), "left_record__description": _(u"Description (left)"), "left_record__datings__period": _(u"Periods (left)"), "relation_type": _(u"Relation type"), "right_record__label": _(u"ID (right)"), "right_record__unit": _(u"Context record type (right)"), "right_record__parcel": _(u"Parcel (right)"), "right_record__description": _(u"Description (right)"), "right_record__datings__period": _(u"Periods (right)") } # search parameters EXTRA_REQUEST_KEYS = { "left_record__operation": "left_record__operation__pk" } class Meta: verbose_name = _(u"Record relation") verbose_name_plural = _(u"Record relations") permissions = [ ("view_recordrelation", u"Can view all Context record relations"), ] post_delete.connect(post_delete_record_relation, sender=RecordRelations) class RecordRelationView(models.Model): CREATE_SQL = """ CREATE VIEW record_relations AS SELECT DISTINCT right_record_id as id, right_record_id, left_record_id, relation_type_id FROM archaeological_context_records_recordrelations; -- deactivate deletion CREATE RULE record_relations_del AS ON DELETE TO record_relations DO INSTEAD DELETE FROM record_relations where id=NULL; """ DELETE_SQL = """ DROP VIEW record_relations; """ TABLE_COLS = [ "relation_type", "right_record__label", "right_record__unit", "right_record__parcel", "right_record__datings__period", "right_record__description"] COL_LABELS = { "relation_type": _(u"Relation type"), "right_record__label": _(u"ID"), "right_record__unit": _(u"Context record type"), "right_record__parcel": _(u"Parcel"), "right_record__description": _(u"Description"), "right_record__datings__period": _(u"Periods") } # search parameters EXTRA_REQUEST_KEYS = { "left_record_id": "left_record_id" } left_record = models.ForeignKey(ContextRecord, related_name='+', on_delete=models.DO_NOTHING) right_record = models.ForeignKey(ContextRecord, related_name='+', on_delete=models.DO_NOTHING) relation_type = models.ForeignKey(RelationType, related_name='+', on_delete=models.DO_NOTHING) class Meta: managed = False db_table = 'record_relations' unique_together = ('id', 'right_record') permissions = [ ("view_recordrelation", u"Can view all record relations - view"), ] @classmethod def general_types(cls): return [] def __unicode__(self): return u"{} \"{}\"".format(self.relation_type, self.right_record)