Browse Source

Init skeletton

omassot 7 năm trước cách đây
mục cha
commit
080146964e

+ 0 - 0
__init__.py


+ 0 - 0
core/__init__.py


+ 159 - 0
core/cerberus_extend.py

@@ -0,0 +1,159 @@
+'''
+
+@author: olivier.massot, 2018
+'''
+from datetime import datetime
+import locale
+import re
+
+import cerberus
+
+from core import gis, gis_
+
+
+def is_french_date(field, value, error):
+    try:
+        datetime.strptime(value, '%d/%m/%Y')
+    except:
+        error(field, 'Doit être une date au format jj/mm/aaaa: {}'.format(value))
+
+def is_modern_french_date(field, value, error):
+    try:
+        d = datetime.strptime(value, '%d/%m/%Y')
+        if not d.year >= 2000:
+            error(field, "La date ne peut pas être antérieure à l'an 2000: {}".format(value))
+    except:
+        error(field, 'Doit être une date au format jj/mm/aaaa: {}'.format(value))
+
+
+def is_int(field, value, error):
+    try:
+        if  float(value) != int(value):
+            error(field, 'Doit être un nombre entier: {}'.format(value))
+    except (TypeError, ValueError):
+        error(field, 'Doit être un nombre entier: {}'.format(value))
+
+def is_multi_int(field, value, error):
+    
+    if not re.match(r"\d(\s?-\s?\d)*", str(value)):
+        error(field, 'Doit être un nombre entier, ou une liste de nombres séparés par des tirets: {}'.format(value))
+        
+def is_float(field, value, error):
+    try:
+        value = locale.atof(str(value))
+        if  value != float(value):
+            error(field, 'Doit être un nombre décimal ({})'.format(value))
+    except ValueError:
+        error(field, 'Doit être un nombre décimal ({})'.format(value))
+
+
+# Ref: http://docs.python-cerberus.org/en/stable/api.html#error-codes
+
+class ExtendedValidator(cerberus.validator.Validator):
+
+    def __init__(self, *args, **kwargs):
+        super(ExtendedValidator, self).__init__(*args, **kwargs)
+        
+        # Rends tous les champs requis par défaut, à moins que 'required' ait été défini dans le schéma
+        for field in self.schema:
+            if not 'required' in self.schema[field]:
+                self.schema[field]['required'] = True
+
+    def _validate_contains_any_of(self, iterable, field, value):
+        """ Controle que la chaine contient l'une des substring contenue dans l'iterable
+
+        The rule's arguments are validated against this schema:
+        {'type': 'list'}
+        """
+        if not re.search(r".*{}.*".format("|".join(iterable)), value):
+            self._error(field, "Doit contenir un de ces éléments: {} ('{}')".format(", ".join(iterable), value))
+        
+    def _validate_multiallowed(self, allowed, field, value):
+        """ Comme 'allowed', mais autorise plusieurs valeurs séparées par un '-'
+
+        The rule's arguments are validated against this schema:
+        {'type': 'list'}
+        """
+        for item in re.split("\s?-\s?", value or ""):
+            if not item in allowed:
+                if item == '':
+                    self._error(field, "Le champs doit être renseigné")
+                else:
+                    self._error(field, "Valeur non-autorisée: {}".format(item))
+
+class GeoValidator(ExtendedValidator):
+    
+    def _validate_inside_box(self, bounding_box, field, value):
+        """ Contrôle l'inclusion de la bounding box de l'entité dans la box donneé
+
+        The rule's arguments are validated against this schema:
+        {'type': 'list'}
+        """
+#         geom_type, bounding_box = geom
+#         
+#         if value.geom_type != geom_type:
+#             self._error(field, "Le type de géométrie est {} (attendu: {})".format(value.geom_name, gis_.GEOM_NAMES[geom_type]))
+    
+        xmin, ymin, xmax, ymax = bounding_box
+        
+        try:
+            x1, y1, x2, y2 = value.bounding_box
+            
+            if any(x < xmin or x > xmax for x in (x1, x2)) or \
+               any(y < ymin or y > ymax for y in (y1, y2)):
+                self._error(field, "Certaines coordonnées hors de l'emprise autorisée")
+        except AttributeError:
+            pass
+
+
+def _translate_messages(message):
+    message = message.replace("string", "texte")
+    message = message.replace("integer", "nombre entier")
+    message = message.replace("float", "nombre décimal")
+    message = message.replace("boolean", "booléen")
+    return message
+
+class CerberusErrorHandler(cerberus.errors.BasicErrorHandler):
+    messages = {0x00: "{0}",
+
+                0x01: "Le document est manquant",
+                0x02: "Le champs est obligatoire",
+                0x03: "Champs inconnu",
+                0x04: "Le champs '{0}' est obligatoire",
+                0x05: "Dépends de ces valeurs: {constraint}",
+                0x06: "{0} ne doit pas être présent avec '{field}'",
+
+                0x21: "'{0}' is not a document, must be a dict",
+                0x22: "Le champs doit être renseigné",
+                0x23: "Les valeurs NULL ne sont pas autorisées",
+                0x24: "Doit être du type {constraint}: {value}",
+                0x25: "Doit être de type 'dictionnaire': {value}",
+                0x26: "La longueur de la liste doit être de {constraint}, elle est de {0}",
+                0x27: "La longueur minimum du champs est de {constraint}: {value}",
+                0x28: "Trop long, la longueur max. du champs est de {constraint}: {value}",
+
+                0x41: "La valeur n'est pas au bon format ('{constraint}')",
+                0x42: "La valeur minimum autorisée est {constraint}",
+                0x43: "La valeur maximum autorisée est {constraint}",
+                0x44: "Valeur non-autorisée: {value}",
+                0x45: "Valeurs non-autorisées: {0}",
+                0x46: "Valeur non-autorisée: {value}",
+                0x47: "Valeurs non-autorisées: {0}",
+
+                0x61: "Le champs '{field}' ne peut pas être converti: {0}",
+                0x62: "Le champs '{field}' ne peut pas être renommé: {0}",
+                0x63: "Le champs est en lecture seule",
+                0x64: "La valeur par défaut du champs '{field}' ne peut pas être appliquée: {0}",
+
+                0x81: "mapping doesn't validate subschema: {0}",
+                0x82: "one or more sequence-items don't validate: {0}",
+                0x83: "one or more keys of a mapping  don't validate: {0}",
+                0x84: "one or more values in a mapping don't validate: {0}",
+                0x85: "one or more sequence-items don't validate: {0}",
+
+                0x91: "one or more definitions validate",
+                0x92: "none or more than one rule validate",
+                0x93: "no definitions validate",
+                0x94: "one or more definitions don't validate"
+                }
+

+ 11 - 0
core/constants.py

@@ -0,0 +1,11 @@
+'''
+
+@author: OMASSOT
+'''
+from path import Path
+
+MAIN = Path(__file__).parent.parent.abspath()
+
+# Dirs
+WRKDIR = MAIN / "work"
+RSCDIR = MAIN / "resources"

+ 75 - 0
core/gis.py

@@ -0,0 +1,75 @@
+'''
+
+@author: olivier.massot, sept. 2018
+'''
+import shapefile
+
+POINT = 1
+POLYLINE = 3
+POLYGON = 5
+SHAPE_NAMES = {0: "(AUCUN)", 
+               1: "POINT", 
+               3:"POLYLIGNE", 
+               5:"POLYGONE",
+               8: "MULTI-POINT",
+               11: "POINT-Z",
+               13: "POLYLIGNE-Z",
+               15: "POLYGONE-Z",
+               18: "MULTIPOINT-Z",
+               21: "POINT-M",
+               23: "POLYLIGNE-M",
+               25: "POLYGONE-M",
+               28: "MULTIPOINT-M",
+               31: "MULTI-PATCH",
+               }
+
+def same_geom(geom1, geom2, buffer = 0):
+    """ retourne vrai si les deux géométries sont identiques """
+    if geom1.shapeType == geom2.shapeType:
+        if geom1.shapeType in (3,5,8,13,15,18,23,25,28,31):
+            return geom1.bbox == geom2.bbox
+        else:
+            return geom1 == geom2
+        
+    return False
+
+class ShapeError(IOError): 
+    pass
+class SridError(IOError): 
+    pass
+
+class ShapeFile():
+    def __init__(self, path_, srid=None):
+        self.path_ = path_
+        self.srid = srid
+    
+    def __enter__(self):
+        if not self.path_.isfile():
+            raise FileNotFoundError("Fichier introuvable")
+     
+        try:
+            self.sf = shapefile.Reader(self.path_)
+        except shapefile.ShapefileException:
+            raise ShapeError("Fichier Shape illisible")
+        
+        if not self.sf.shapeType in (POINT, POLYLINE, POLYGON):
+            raise ShapeError("Type de géométrie non reconnue")
+
+#         if self.srid is not None:
+#             if not self.sf.srid == self.srid:
+#                 raise SridError("Le SRID du fichier doit être '{}'".format(self.srid))
+
+        return self
+    
+    def shape(self):
+        return self.sf.shapeType
+    
+    def fields(self):
+        return [f[0] for f in self.sf.fields if f[0] != 'DeletionFlag']
+        
+    def records(self):
+        for record in self.sf.shapeRecords():
+            yield record
+        
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        del self.sf

+ 178 - 0
core/gis_.py

@@ -0,0 +1,178 @@
+'''
+
+@author: olivier.massot, 2018
+'''
+from osgeo import ogr, osr
+from path import Path
+
+
+GEOM_UNKNOWN = 0
+GEOM_POINT = 1
+GEOM_LINE = 2
+GEOM_POLYGON = 3
+GEOM_MULTIPOINT = 4
+GEOM_MULTILINE = 5
+GEOM_MULTIPOLYGON = 6
+
+GEOM_NAMES = {0: "(AUCUN)", 
+               1: "POINT", 
+               2: "LIGNE", 
+               3: "POLYGONE", 
+               4: "MULTI-POINT", 
+               5:"MULTI-LIGNE", 
+               6:"MULTI-POLYGONE",
+               }
+
+def point_(x, y):
+    point = ogr.Geometry(ogr.wkbPoint)
+    point.AddPoint(x, y)
+    return point
+
+class Datasource():
+    DRIVER_NAME = "ESRI Shapefile"
+    def __init__(self, filename, readonly=True):
+        self.filename = Path(filename)
+        driver = ogr.GetDriverByName(self.DRIVER_NAME)
+        self._ogr_datasource = driver.Open(filename, 0 if readonly else 1)
+        if not self._ogr_datasource:
+            raise IOError("Unable to read the file {}".format(filename))
+    
+    @property
+    def layer(self):
+        return Layer(self._ogr_datasource.GetLayer())
+    
+    
+class LayerField():
+    def __init__(self, ogr_field):
+        self.name =  ogr_field.GetName()
+        self.type_ = ogr_field.GetType()
+        self.type_name = ogr_field.GetFieldTypeName(self.type_)
+        self.width = ogr_field.GetWidth()
+        self.precision = ogr_field.GetPrecision()
+    
+    def __repr__(self):
+        return "<Field: nom={}, type={}, longueur={}>".format(self.name, self.type_name, self.width)
+    
+class Layer():
+    def __init__(self, ogr_layer):
+        self._ogr_layer = ogr_layer
+        
+        self.name = ogr_layer.GetName()
+        self.srid = ogr_layer.GetSpatialRef().GetAttrValue('AUTHORITY',1)
+        self.geom_type = ogr_layer.GetGeomType()
+        _layer_def = self._ogr_layer.GetLayerDefn()
+        self.fields = [LayerField(_layer_def.GetFieldDefn(i)) for i in range(_layer_def.GetFieldCount())]
+    
+    @property
+    def geom_name(self):
+        return GEOM_NAMES[self._ogr_layer.GetGeomType()]
+    
+    def __len__(self):
+        return self._ogr_layer.GetFeatureCount()
+    
+    def __iter__(self):
+        for f in self._ogr_layer.__iter__():
+            yield Feature(f)
+        self._ogr_layer.ResetReading()
+
+    def __getitem__(self, i):
+        return Feature(self._ogr_layer.GetFeature(i))
+
+
+class Feature():
+    def __init__(self, ogr_feature):
+        self._ogr_feature = ogr_feature
+        
+        self.geom = self._ogr_feature.GetGeometryRef()
+        self.geom_type = self.geom.GetGeometryType()
+        
+        xmin, xmax, ymin, ymax = self.geom.GetEnvelope()
+        self.bounding_box = (xmin, ymin, xmax, ymax)
+        
+        _def = ogr_feature.GetDefnRef()
+        for i in range(_def.GetFieldCount()):
+            field = _def.GetFieldDefn(i)
+            value = ogr_feature[i]
+            if value is None and field.GetType() == ogr.OFTString:
+                value = ""
+            self.__setattr__(field.GetName(), value)
+        
+    @property
+    def geom_name(self):
+        return GEOM_NAMES[self.geom.GetGeometryType()]
+    
+    def fields(self):
+        layerDefinition = self._ogr_layer.GetLayerDefn()
+        return [LayerField(layerDefinition.GetFieldDefn(i)) for i in range(layerDefinition.GetFieldCount())]
+    
+    @property
+    def valid(self):
+        return self.geom.IsValid()
+    
+    def __repr__(self):
+        return "<Feature: type={}, bounding_box={}>".format(self.geom_name, self.bounding_box)
+    
+    @property
+    def points(self):
+        if self.geom_type != GEOM_POLYGON:
+            return [point_(*p) for p in self.geom.GetPoints()]
+        else:
+            return [point_(*p) for p in self.geom.GetGeometryRef(0).GetPoints()]
+
+    def almost_equal(self, feature, buffer_dist=1):
+        if feature.geom_type == self.geom_type:
+            buffer = self.geom.Buffer(buffer_dist)
+            if buffer.Contains(feature.geom):
+                return True
+        return False
+    
+    @classmethod
+    def union(cls, features):
+        g = features[0].geom.Clone()
+        for f in features[1:]:
+            g = g.Union(f.geom)
+        return g
+    
+    @classmethod
+    def buffered_union(cls, features, buffer_dist=1):
+        multi  = ogr.Geometry(ogr.wkbMultiPolygon)
+        for f in features:
+            geom = f.geom if issubclass(f.__class__, Feature) else f # au cas où ce ne seraient pas des features, mais directement des geometries
+            multi.AddGeometry(geom.Buffer(buffer_dist))
+        return multi.UnionCascaded()
+    
+if __name__ == "__main__":
+    ds = Datasource(r"C:\dev\python\datacheck\work\STURNO_178AP1_REC_171121_OK\ARTERE_GEO.shp")
+    layer = ds.layer
+    print(layer.fields)
+#     for f in layer:
+#         print(f)
+    f = layer[4]
+    f2 = layer[5]
+    f3 = layer[4]
+    print(f)
+    print(f.geom)
+    print(f.AR_ID_INSE)
+    
+    gf = Feature.union(list(layer))
+    print(gf)
+#     gf = Feature.buffered_union(list(layer), 0.5)
+#     print(gf)
+    
+    # save to file
+    result_file = Path(r"C:\dev\python\datacheck\work\result.shp")
+    driver = ogr.GetDriverByName("ESRI Shapefile")
+    if result_file.exists():
+        driver.DeleteDataSource(result_file)
+        
+    data_source = driver.CreateDataSource(result_file)
+    srs = osr.SpatialReference()
+    srs.ImportFromEPSG(3949)
+    
+    layer = data_source.CreateLayer("result", srs, gf.GetGeometryType())
+    feature = ogr.Feature(layer.GetLayerDefn())
+    feature.SetGeometry(gf)
+    layer.CreateFeature(feature)
+    
+    
+    

+ 34 - 0
core/sqlformatter.py

@@ -0,0 +1,34 @@
+'''
+
+    Formatter special pour les requetes SQL
+
+    usage:
+
+        Sql = SqlFormatter()
+
+        my_query = Sql.format("SELECT {} FROM {}", "my_field", "tblname")
+
+@author: olivier.massot
+'''
+import string
+
+
+class SqlFormatter(string.Formatter):
+
+    def format_field(self, value, format_spec):
+
+        if value is None:
+            return "Null"
+
+        if format_spec == "date":
+            return "#{:%Y-%m-%d %H:%M:%S}#".format(value)
+        elif format_spec == "text":
+            return "'{}'".format(SqlFormatter._escape(str(value)))
+        elif format_spec == "float":
+            return str(value).replace(",", ".")
+        else:
+            return SqlFormatter._escape(value.__format__(format_spec))
+
+    @staticmethod
+    def _escape(instr):
+            return instr.replace("'", "''").replace("\"", "''").replace("\t", " ")

+ 630 - 0
core/validation.py

@@ -0,0 +1,630 @@
+'''
+
+
+    @author: olivier.massot, sept. 2018
+'''
+import time
+import zipfile
+
+from path import Path, TempDir
+
+from core import gis_
+from core.cerberus_extend import CerberusErrorHandler, \
+    _translate_messages, ExtendedValidator
+from schemas.common import SRID
+
+        
+class ValidatorInterruption(BaseException): 
+    pass
+
+class Checkpoint():
+    def __init__(self, name, valid=True):
+        self.name = name
+        self.valid = valid
+
+
+###########    MODELES    ################
+
+class BaseModel():
+    filename = ""
+    pk = ""
+    schema = {}
+    def __init__(self, **kwargs):
+        self.__dict__.update(kwargs)
+        
+class BaseGeoModel(gis_.Feature):
+    filename = ""
+    pk = ""
+    geom_type = 0
+    bounding_box = (0,0,1,1)
+    schema = {}
+    
+    def __init__(self, feature):
+        self.__dict__.update(feature.__dict__)
+
+
+###########    ERREURS DE VALIDATION     ################
+
+VALIDATION_ERROR_LEVELS = {10: "MINEURE", 20: "AVERTISSEMENT", 30: "ERREUR", 40: "CRITIQUE"}
+MINOR = 10
+WARNING = 20
+ERROR = 30
+CRITICAL = 40
+
+class BaseValidationError():
+    order_ = 0
+    name = "Erreur"
+    level = ERROR
+    help = ""
+    def __init__(self, message, filename="", field=""):
+        self.message = message
+        self.filename = filename
+        self.field = field
+        
+    def __repr__(self):
+        return " - ".join(filter(None, [self.name, self.filename, self.field, self.message]))
+
+# Erreurs dans le chargement des fichiers
+class InputError(BaseValidationError):
+    order_ = 0
+    level = CRITICAL
+    name = "Erreur de chargement"
+
+class MissingFile(InputError):
+    order_ = 1
+    name = "Fichier Manquant"
+    
+class UnreadableFile(InputError):
+    order_ = 2
+    name = "Fichier Illisible"
+
+class WrongSrid(InputError):
+    order_ = 3
+    name = "Mauvais SRID"
+
+### Erreurs dans la structure des données
+class StructureError(BaseValidationError):
+    order_ = 10
+    name = "Erreur de structure"
+    level = ERROR
+    
+class GeomTypeError(StructureError):
+    order_ = 12
+    name = "Type de géométrie invalide"
+    level = CRITICAL
+    
+class BoundingBoxError(StructureError):
+    order_ = 11
+    name = "Coordonnées hors de la zone autorisée"
+    
+class InvalidGeometry(StructureError):
+    order_ = 13
+    name = "Géométrie invalide"
+    
+class DataError(StructureError):
+    order_ = 14
+    name = "Erreur de format"
+
+# Erreurs dans le contenu, erreurs métiers
+class TechnicalValidationError(BaseValidationError):
+    order_ = 20
+    level = ERROR
+    name = "Erreur technique"
+
+class UniqueError(TechnicalValidationError):
+    order_ = 21
+    name = "Doublons dans le champs"
+
+class RelationError(TechnicalValidationError):
+    order_ = 22
+    level = CRITICAL
+    name = "Un objet lié n'existe pas"
+
+class DuplicatedGeom(TechnicalValidationError):
+    order_ = 23
+    name = "Doublon graphique"
+
+class MissingItem(TechnicalValidationError):
+    order_ = 24
+    name = "Elément manquant"
+
+class DimensionError(TechnicalValidationError):
+    order_ = 25
+    name = "Elément de dimension"
+
+class PositionError(TechnicalValidationError):
+    order_ = 26
+    name = "Erreur de positionnement"
+    
+###########    VALIDATION    ################
+
+class BaseValidator():
+    schema_name = ""
+    models = {}
+    dataset = {}
+    
+    def __init__(self):
+        self.valid = True
+        self.checkpoints = []
+        self.errors = []
+        self._current_checkpoint_valid = True
+        self.dt = 0
+    
+    def checkpoint(self, title):
+        self.checkpoints.append(Checkpoint(title, self._current_checkpoint_valid))
+        self._current_checkpoint_valid = True
+        if self.errors:
+            self.valid = False
+            if self.critical_happened():
+                raise ValidatorInterruption()
+             
+    def critical_happened(self):
+        return any([err.level == CRITICAL for err in self.errors])
+             
+    def log_error(self, validation_error):
+        self._current_checkpoint_valid = False
+        self.errors.append(validation_error)
+         
+    @classmethod
+    def submit(cls, subject):    
+        """ prends un dossier ou une archive en entrée et vérifie son contenu  """
+        subject = Path(subject)
+        
+        if subject.isfile():
+            with TempDir() as dirname:
+                zip_ref = zipfile.ZipFile(subject, 'r')
+                zip_ref.extractall(dirname)
+                zip_ref.close()
+                if Path(dirname / subject.stem).isdir(): # cas où l'archive contient un dossier qui lui-même contient les fichiers
+                    dirname /= subject.stem
+                return cls._submit_folder(dirname)
+            
+        elif subject.isdir():
+            return cls._submit_folder(subject)
+        else:
+            raise FileNotFoundError(f"Impossible de trouver le fichier ou répertoire: {subject}")
+
+    @classmethod
+    def _submit_folder(cls, folder):
+        
+        validator = cls()
+        t0 = time.time()
+        try:
+            validator.validate(folder)
+        except ValidatorInterruption:
+            pass
+        validator.dt = time.time() - t0
+        
+        report = validator.build_report(validator.schema_name, folder.name)
+        return report
+    
+    def validate(self, folder):
+        
+        # Chargement des données en mémoire
+        self._load_files(folder)
+        self.checkpoint("Chargement des données")
+        
+        # Controle la structure des données (champs, formats et types)
+        self._structure_validation()
+        self.checkpoint("Contrôle de la structure des données")
+        
+        # Validation technique
+        try:
+            self._technical_validation()
+            self.checkpoint("Validation Métier")
+        except ValidatorInterruption:
+            raise
+        except:
+            self.checkpoint("Validation Métier [interrompu]")
+    
+    def _load_files(self, folder):
+        """ Charge les données du fichier et les associe à un modèle.
+        Attention: pas de contrôle de format ou de validité à ce niveau!  """
+        raise NotImplementedError()
+    
+    def _structure_validation(self):
+        
+        for model in self.models:
+            v = ExtendedValidator(model.schema, purge_unknown=True, error_handler=CerberusErrorHandler, require_all=True)
+            
+            for item in self.dataset[model]:
+
+                v.validate(item.__dict__)
+            
+                for field, verrors in v.errors.items():
+                    for err in verrors:
+                        self.log_error(DataError(_translate_messages(err), filename=model.filename, field=field))
+    
+    @classmethod
+    def _technical_validation(cls):
+        raise NotImplementedError()
+    
+    
+    def build_report(self, schema, filename):
+        report = {}
+        report["schema"] = schema
+        report["filename"] = filename
+        report["exec_time"] = "{:.3g} s.".format(self.dt)
+        report["checkpoints"] = [{"name": chk.name, "valid": chk.valid} for chk in self.checkpoints]
+        
+        report["errors"] = {}
+        
+        for err in self.errors:
+            if not err.name in report["errors"]:
+                report["errors"][err.name] = {"help": err.help, "order_": err.order_, "list": []}
+            
+            err_report = {"filename": err.filename or "-",
+                          "field": err.field or "-",
+                          "message": err.message}
+            if err_report not in report["errors"][err.name]["list"]:
+                report["errors"][err.name]["list"].append(err_report)
+            
+        return report
+    
+        
+class NetgeoValidator(BaseValidator):
+    
+    def _load_files(self, folder):
+        
+        for model in self.models:
+            filename = model.filename
+            path_ = Path(folder) / filename
+            
+            if not path_.isfile():
+                self.log_error(MissingFile("Fichier manquant: '{}'".format(filename)))
+                continue
+            
+            self.dataset[model] = []
+            try:
+                
+                ds = gis_.Datasource(path_)
+                layer = ds.layer
+                
+                if layer.srid != SRID:
+                    self.log_error(WrongSrid("Mauvaise projection: {} (attendu: {})".format(layer.srid, SRID)))
+                
+                for feature in layer:
+                    
+                    item = model(feature)
+                    
+                    self.dataset[model].append(item)
+                        
+            except IOError:
+                self.log_error(UnreadableFile("Fichier illisible: {}".format(path_.name)))
+    
+    def _structure_validation(self):
+        
+        for model in self.models:
+            v = ExtendedValidator(model.schema, purge_unknown=True, error_handler=CerberusErrorHandler, require_all=True)
+            xmin, ymin, xmax, ymax = model.bounding_box
+            
+            for item in self.dataset[model]:
+
+                # geom type
+                if item.geom_type != model.geom_type:
+                    self.log_error(GeomTypeError("Type de géométrie invalide: {} (attendu: {})".format(item.geom_name, gis_.GEOM_NAMES[model.geom_type]), filename=model.filename, field="geom"))
+
+                # bounding box
+                x1, y1, x2, y2 = item.bounding_box
+                if any(x < xmin or x > xmax for x in (x1, x2)) or \
+                   any(y < ymin or y > ymax for y in (y1, y2)):
+                    self.log_error(BoundingBoxError("Situé hors de l'emprise autorisée", filename=model.filename, field="geom"))
+
+                v.validate(item.__dict__)
+               
+                for field, verrors in v.errors.items():
+                    for err in verrors:
+                        self.log_error(DataError(_translate_messages(err), filename=model.filename, field=field))
+    
+#     def _technical_validation(self):
+#         
+#         # construction des index
+#         arteres = self.dataset[Artere]
+#         cables = self.dataset[Cable]
+#         tranchees = self.dataset[Tranchee]
+#         
+#         noeuds = {}
+#         for noeud in self.dataset[Noeud]:
+#             if not noeud.NO_NOM in noeuds:
+#                 noeuds[noeud.NO_NOM] = noeud
+#             else:
+#                 self.log_error(UniqueError("Doublons dans le champs: {}".format(noeud), filename=Noeud.filename, field="NO_NOM"))
+#         
+#         equipements = {}
+#         for equipement in self.dataset[Equipement]:
+#             if not equipement.EQ_NOM in equipements:
+#                 equipements[equipement.EQ_NOM] = equipement
+#             else:
+#                 self.log_error(UniqueError("Doublons dans le champs: {}".format(equipement), filename=Equipement.filename, field="EQ_NOM"))
+#                 
+#         zapbos = {}
+#         for zapbo in self.dataset[Zapbo]:
+#             if not zapbo.ID_ZAPBO in zapbos:
+#                 zapbos[zapbo.ID_ZAPBO] = zapbo
+#             else:
+#                 self.log_error(UniqueError("Doublons dans le champs: {}".format(zapbo), filename=Zapbo.filename, field="ID_ZAPBO"))
+#         
+#         # contrôle de la validité des géométries
+#         for artere in arteres:
+#             if not artere.valid:
+#                 self.log_error(InvalidGeometry("Géométrie invalide: {}".format(artere), filename=Artere.filename, field="geom"))
+#         for tranchee in tranchees:
+#             if not tranchee.valid:
+#                 self.log_error(InvalidGeometry("Géométrie invalide: {}".format(tranchee), filename=Tranchee.filename, field="geom"))
+#         for cable in cables:
+#             if not "baguette" in cable.CA_COMMENT.lower() and not cable.valid:
+#                 self.log_error(InvalidGeometry("Géométrie invalide: {}".format(cable), filename=Cable.filename, field="geom"))
+#         for noeud in noeuds.values():
+#             if not noeud.valid:
+#                 self.log_error(InvalidGeometry("Géométrie invalide: {}".format(noeud), filename=Noeud.filename, field="geom"))
+#         for equipement in equipements.values():
+#             if not equipement.valid:
+#                 self.log_error(InvalidGeometry("Géométrie invalide: {}".format(equipement), filename=Equipement.filename, field="geom"))
+#         for zapbo in zapbos.values():
+#             if not zapbo.valid:
+#                 self.log_error(InvalidGeometry("Géométrie invalide: {}".format(zapbo), filename=Zapbo.filename, field="geom"))
+#         
+#         # rattachement les noeuds aux artères     
+#         for artere in arteres:
+#             try:
+#                 artere.noeud_a = noeuds[artere.AR_NOEUD_A]
+#             except KeyError:
+#                 artere.noeud_a = None
+#                 self.log_error(RelationError("Le noeud '{}' n'existe pas".format(artere.AR_NOEUD_A), filename=Artere.filename, field="AR_NOEUD_A"))
+#                 
+#             try:
+#                 artere.noeud_b = noeuds[artere.AR_NOEUD_B]
+#             except KeyError:
+#                 artere.noeud_b = None
+#                 self.log_error(RelationError("Le noeud '{}' n'existe pas".format(artere.AR_NOEUD_B), filename=Artere.filename, field="AR_NOEUD_A"))
+#         
+#         # rattachement des equipements aux cables
+#         for cable in cables:
+#             try:
+#                 cable.equipement_a = equipements[cable.CA_EQ_A]
+#             except KeyError:
+#                 cable.equipement_a = None
+#                 self.log_error(RelationError("L'équipement '{}' n'existe pas".format(cable.CA_EQ_A), filename=Cable.filename, field="CA_EQ_A"))
+#                 
+#             try:
+#                 cable.equipement_b = equipements[cable.CA_EQ_B]
+#             except KeyError:
+#                 cable.equipement_b = None
+#                 self.log_error(RelationError("L'équipement '{}' n'existe pas".format(cable.CA_EQ_B), filename=Cable.filename, field="CA_EQ_B"))
+# 
+#         # rattachement des equipements aux noeuds
+#         for equipement in equipements.values():
+#             try:
+#                 equipement.noeud = noeuds[equipement.EQ_NOM_NOE]
+#             except KeyError:
+#                 equipement.noeud = None
+#                 self.log_error(RelationError("Le noeud '{}' n'existe pas".format(equipement.EQ_NOM_NOE, equipement.EQ_NOM), filename=Equipement.filename, field="EQ_NOM_NOE"))
+# 
+#         # verifie que tous les equipements sont l'equipement B d'au moins un cable
+#         equipements_b = [cable.CA_EQ_B for cable in cables]
+#         for eq_id in equipements:
+#             if equipements[eq_id].EQ_TYPE == "BAI":
+#                 continue
+#             if not eq_id in equipements_b:
+#                 self.log_error(RelationError("L'equipement '{}' n'est l'équipement B d'aucun cable".format(eq_id), filename=Equipement.filename, field="EQ_NOM"))
+# 
+#         # controle des doublons graphiques
+#         for i, tranchee in enumerate(tranchees):
+#             for other in tranchees[i+1:]:
+#                 if tranchee.geom == other.geom:
+#                     self.log_error(DuplicatedGeom("Une entité graphique est dupliquée".format(tranchee), filename=Tranchee.filename, field="geom"))
+#                     
+#         for i, artere in enumerate(arteres):
+#             for other in arteres[i+1:]:
+#                 if artere.geom == other.geom:
+#                     self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(artere), filename=Artere.filename, field="geom"))
+# 
+#         for i, cable in enumerate(cables):
+#             for other in cables[i+1:]:
+#                 if cable.geom == other.geom and cable.CA_EQ_A == other.CA_EQ_A and cable.CA_EQ_B == other.CA_EQ_B:
+#                     self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(cable), filename=Cable.filename, field="geom"))
+#         
+#         ls_noeuds = list(noeuds.values())
+#         for i, noeud in enumerate(ls_noeuds):
+#             for other in ls_noeuds[i+1:]:
+#                 if noeud.geom == other.geom:
+#                     self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(noeud), filename=Noeud.filename, field="geom"))
+#         del ls_noeuds
+#         
+#         ls_zapbos = list(zapbos.values())
+#         for i, zapbo in enumerate(ls_zapbos):
+#             for other in ls_zapbos[i+1:]:
+#                 if zapbo.geom == other.geom:
+#                     self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(zapbo), filename=Zapbo.filename, field="geom"))
+#         del ls_zapbos
+#            
+#         # Arteres: comparer la géométrie à celle des noeuds
+#         for artere in arteres:
+#             if not artere.noeud_a or not artere.noeud_b:
+#                 continue
+#             
+#             buffer_a, buffer_b = artere.points[0].Buffer(TOLERANCE), artere.points[-1].Buffer(TOLERANCE)
+#             
+#             if not (buffer_a.Contains(artere.noeud_a.points[0]) and buffer_b.Contains(artere.noeud_b.points[0])) \
+#                and not (buffer_a.Contains(artere.noeud_b.points[0]) and buffer_b.Contains(artere.noeud_a.points[0])):
+# 
+#                 self.log_error(MissingItem("Pas de noeud aux coordonnées attendues ('{}')".format(artere), filename=Artere.filename, field="geom"))
+#         
+#         
+#         # Cables: comparer la géométrie à celle des equipements (on utilise en fait la geom du noeud correspondant à l'équipement)
+#         for cable in cables:
+#             if not cable.equipement_a or not cable.equipement_b or not cable.valid or not cable.equipement_a.noeud or not cable.equipement_b.noeud:
+#                 continue
+#             
+#             buffer_a, buffer_b = cable.points[0].Buffer(TOLERANCE), cable.points[-1].Buffer(TOLERANCE)
+#             
+#             if not (buffer_a.Contains(cable.equipement_a.noeud.points[0]) and buffer_b.Contains(cable.equipement_b.noeud.points[0])) \
+#                and not (buffer_a.Contains(cable.equipement_b.noeud.points[0]) and buffer_b.Contains(cable.equipement_a.noeud.points[0])):
+#             
+#                 self.log_error(MissingItem("Pas d'equipement aux coordonnées attendues ('{}')".format(cable), filename=Cable.filename, field="geom"))
+#             
+#         del buffer_a, buffer_b 
+#         
+#         # Verifie que chaque tranchée a au moins une artère
+#         arteres_emprise = Feature.buffered_union(arteres, TOLERANCE)
+#         
+#         for tranchee in tranchees:
+#             if not arteres_emprise.Contains(tranchee.geom):
+#                 self.log_error(MissingItem("Tranchée sans artère ('{}')".format(tranchee), filename=Tranchee.filename, field="-"))
+#         
+#         
+#         # Verifie que chaque cable a au moins une artère (sauf si commentaire contient 'baguette')
+#         for cable in cables:
+#             if "baguette" in cable.CA_COMMENT.lower() or not cable.valid:
+#                 continue
+#             if not arteres_emprise.Contains(cable.geom):
+#                 self.log_error(MissingItem("Cable sans artère ('{}')".format(cable), filename=Cable.filename, field="-"))
+#         
+#         del arteres_emprise
+#         
+#         # Verifie que chaque artère a au moins un cable (sauf si commentaire contient un de ces mots 'racco client adductio attente bus 'sans cable'')
+#         cables_emprise = Feature.buffered_union(cables, TOLERANCE)
+#         
+#         for artere in arteres:
+#             if any(x in artere.AR_COMMENT.lower() for x in ['racco','client','adductio','attente','bus','sans cable']):
+#                 continue
+#             if not cables_emprise.Contains(artere.geom):
+#                 self.log_error(MissingItem("Artère sans cable ('{}')".format(artere), filename=Artere.filename, field="-"))
+#                 
+#         del cables_emprise
+#                 
+#         # Contrôle des dimensions logiques
+#         for artere in arteres:
+#             try:
+#                 if not int(artere.AR_FOU_DIS) <= int(artere.AR_NB_FOUR):
+#                     self.log_error(DimensionError("Le nombre de fourreaux disponibles doit être inférieur au nombre total ('{}')".format(artere), filename=Artere.filename, field="AR_FOU_DIS"))
+#             except (TypeError, ValueError):
+#                 pass
+#         
+#         for cable in cables:
+#             try:
+#                 if not int(cable.CA_NB_FO_U) <= int(cable.CA_NB_FO):
+#                     self.log_error(DimensionError("Le nombre de fourreaux utilisés doit être inférieur au nombre total ('{}')".format(cable), filename=Cable.filename, field="CA_NB_FO_U"))
+#                 if not int(cable.CA_NB_FO_D) <= int(cable.CA_NB_FO):
+#                     self.log_error(DimensionError("Le nombre de fourreaux disponibles doit être inférieur au nombre total ('{}')".format(cable), filename=Cable.filename, field="CA_NB_FO_D"))
+#             except (TypeError, ValueError):
+#                 pass
+#         
+#         ant_db = mn.ANTDb_0()
+#         ant_db.execute("alter session set NLS_NUMERIC_CHARACTERS = '.,';") # definit le separateur decimal sur '.'
+#         
+#         # Toutes les zapbo contiennent au moins une prise
+#         for zapbo in zapbos.values():
+#             
+#             if len(zapbo.points) >= 499:
+#                 # passe l'erreur provoquée par la limite au nombre d'arguments en SQL
+#                 zapbo.nb_prises = None
+#                 continue
+#             
+#             sql = """Select SUM(NB_PRISE) AS NB_PRISES FROM SIG_ANT.FTTH_MN_PRISE_LOT z 
+#                        WHERE SDO_INSIDE(z.GEOMETRY,
+#                                       SDO_GEOMETRY(2003, 3949, SDO_POINT_TYPE(null,null,null), SDO_ELEM_INFO_ARRAY(1,1003,1),  SDO_ORDINATE_ARRAY({}))
+#                                          )='TRUE';""".format(", ".join(["{},{}".format(p.GetX(), p.GetY()) for p in zapbo.points]))
+#             
+#             zapbo.nb_prises = int(ant_db.first(sql).NB_PRISES)
+#             if not zapbo.nb_prises:
+#                 self.log_error(MissingItem("La Zapbo ne contient aucune prise: {}".format(zapbo), filename=Zapbo.filename, field="-"))
+#         
+#         # Toutes les prises de la ou les ZAPM impactées sont dans une zapbo
+#         zapms = {}
+#         # > on déduit la liste des zapms à partir de la position des zapbos
+#         for zapbo in zapbos.values():
+#             centre = zapbo.geom.Centroid()
+#             zapm = ant_db.first("""SELECT z.ID_ZAPM 
+#                                   FROM SIG_ANT.FTTH_MN_ZAPM z
+#                                   WHERE sdo_contains(z.GEOMETRY, 
+#                                                      SDO_GEOMETRY(2001, 3949, SDO_POINT_TYPE({}, {}, NULL), NULL, NULL)) = 'TRUE'
+#                                """.format(centre.GetX(), centre.GetY()))
+#             try:
+#                 zapms[zapm.ID_ZAPM].append(zapbo)
+#             except KeyError:
+#                 zapms[zapm.ID_ZAPM] = [zapbo]
+#         
+#         for id_zapm in zapms:
+#             zapm_couverture = Feature.union(zapms[id_zapm])
+#             for prise in ant_db.read("""SELECT t.X AS x, t.Y AS y
+#                                         FROM SIG_ANT.FTTH_MN_PRISE_LOT z, 
+#                                         TABLE(SDO_UTIL.GETVERTICES(z.GEOMETRY)) t 
+#                                         WHERE T_ETAT<>'OBSOLETE' AND ID_ZAPM_PARTIELLE='{}';""".format(id_zapm)):
+#                 point = ogr.Geometry(ogr.wkbPoint)
+#                 point.AddPoint(prise.x, prise.y)
+#                 if not zapm_couverture.Contains(point):
+#                     self.log_error(MissingItem("Certaines prises de la ZAPM ne sont pas comprises dans une ZAPBO: {}".format(id_zapm), filename=Zapbo.filename, field="-"))
+#         
+#         # Verifier que chaque equipement de type PBO est contenu dans une zapbo, et que le nom de la zapbo contient le nom de l'equipement
+#         
+#         for equipement in equipements.values():
+#             if not equipement.EQ_TYPE == "PBO":
+#                 continue
+#             
+#             #zapbos englobant l'equipement
+#             candidates = []
+#             for zapbo in zapbos.values():
+#                 if zapbo.geom.Contains(equipement.geom):
+#                     candidates.append(zapbo)
+#                     
+#             # le pbo doit être contenu dans une zapbo
+#             if not candidates:
+#                 self.log_error(MissingItem("Le PBO n'est contenu dans aucune ZAPBO: {}".format(equipement), filename=Equipement.filename, field="geom"))
+#                 continue
+#             
+#             # On se base sur le nom pour trouver la zapbo correspondante
+#             try:
+#                 equipement.zapbo = next((z for z in candidates if equipement.EQ_NOM in z.ID_ZAPBO))
+#             except StopIteration:
+#                 self.log_error(MissingItem("Le nom du PBO ne coincide avec le nom d'aucune des ZAPBO qui le contient: {}".format(equipement), filename=Equipement.filename, field="EQ_NOM"))
+#                 break
+#             
+#             # Controle du dimensionnement des PBO
+#             if equipement.zapbo.nb_prises is not None:
+#                 if equipement.EQ_TYPE_PH == 'PBO 6' and not equipement.zapbo.nb_prises < 6:
+#                     self.log_error(DimensionError("Le PBO 6 contient plus de 5 prises: {}".format(equipement), filename=Equipement.filename, field="-"))
+#             
+#                 if equipement.EQ_TYPE_PH == 'PBO 12' and not equipement.zapbo.nb_prises >= 6 and equipement.zapbo.nb_prises <= 8:
+#                     self.log_error(DimensionError("Le PBO 12 contient mois de 6 prises ou plus de 8 prises: {}".format(equipement), filename=Equipement.filename, field="-"))
+#         
+#             if equipement.zapbo.STATUT == "REC" and not equipement.EQ_STATUT == "REC":
+#                 self.log_error(TechnicalValidationError("Le statut du PBO n'est pas cohérent avec le statut de sa ZAPBO: {}".format(equipement), filename=Equipement.filename, field="-"))
+#         
+#             if equipement.EQ_STATUT == "REC" and not equipement.zapbo.STATUT == "REC" and not equipement.zapbo.ID_ZAPBO[:4].lower() == "att_":
+#                 self.log_error(TechnicalValidationError("Le statut du PBO n'est pas cohérent avec le statut de sa ZAPBO: {}".format(equipement), filename=Equipement.filename, field="-"))
+#         
+#         # Contrôler dans la base si des éléments portant ces codes existent à des emplacements différents
+#         for noeud in noeuds.values():
+#             sql = """SELECT z.NO_NOM, SDO_GEOM.SDO_DISTANCE(z.GEOMETRY, SDO_GEOMETRY(2001, 3949, SDO_POINT_TYPE({}, {}, NULL), NULL, NULL),0.005) AS DIST 
+#                      FROM SIG_ANT.FTTH_MN_GR_NOEUD_GEO z 
+#                      WHERE z.NO_NOM='{}';""".format(noeud.geom.GetX(), noeud.geom.GetY(), noeud.NO_NOM)
+#             existing = ant_db.first(sql)
+#             if existing:
+#                 if existing.DIST > TOLERANCE and existing.DIST < 20:
+#                     self.log_error(PositionError("La position du noeud ne correspond pas à l'existant: {}".format(noeud), filename=Noeud.filename, field="geom"))
+#                 elif existing.DIST > 20:
+#                     self.log_error(DuplicatedGeom("Un noeud portant ce nom existe déjà ailleurs sur le territoire: {}".format(noeud), filename=Noeud.filename, field="NO_NOM"))
+#         
+#         for zapbo in zapbos.values():
+#             sql = """SELECT z.ID_ZAPBO, SDO_GEOM.SDO_DISTANCE(SDO_GEOM.SDO_CENTROID(z.GEOMETRY,0.005), SDO_GEOMETRY(2001, 3949, SDO_POINT_TYPE({}, {}, NULL), NULL, NULL),0.005) AS DIST 
+#                      FROM SIG_ANT.FTTH_MN_GR_ZAPBO_GEO z 
+#                      WHERE z.ID_ZAPBO='{}';""".format(zapbo.geom.Centroid().GetX(), zapbo.geom.Centroid().GetY(), zapbo.ID_ZAPBO)
+#             existing = ant_db.first(sql)
+#             if existing:
+#                 if existing.DIST > TOLERANCE and existing.DIST < 20:
+#                     self.log_error(PositionError("La position de la ZAPBO ne correspond pas à l'existant: {}".format(zapbo), filename=Zapbo.filename, field="geom"))
+#                 elif existing.DIST > 20:
+#                     self.log_error(DuplicatedGeom("Une ZAPBO portant ce nom existe déjà ailleurs sur le territoire: {}".format(zapbo), filename=Zapbo.filename, field="ID_ZAPBO"))
+# 
+#         # Contrôle si un equipement portant ce nom existe, mais associé à un noeud différent
+#         for equipement in equipements.values():
+#             sql = """SELECT z.EQ_NOM, z.EQ_NOM_NOEUD
+#                      FROM SIG_ANT.FTTH_MN_GR_EQ_PASSIF z 
+#                      WHERE z.EQ_NOM='{}';""".format(equipement.EQ_NOM)
+#             existing = ant_db.first(sql)
+#             if existing and existing.EQ_NOM_NOEUD != equipement.EQ_NOM_NOE:
+#                 self.log_error(DuplicatedGeom("Un équipement portant ce nom ({}) existe déjà et est associé à un noeud différent ({})".format(equipement.NO_NOM, existing.EQ_NOM_NOEUD), filename=Noeud.filename, field="geom"))
+#         
+#         
+    

+ 39 - 0
metadata.txt

@@ -0,0 +1,39 @@
+# This file contains metadata for your plugin. Since 
+# version 2.0 of QGIS this is the proper way to supply 
+# information about a plugin. The old method of 
+# embedding metadata in __init__.py will 
+# is no longer supported since version 2.0.
+
+# This file should be included when you package your plugin.# Mandatory items:
+
+[general]
+name=MnCheck
+qgisMinimumVersion=3.0
+description=Contrôle des données FTTH format MN
+version=0.1
+author=Manche Numérique 2019
+email=olivier.massot@manchenumerique.fr
+
+about=Auto-contrôle des livrables FTTH aux formats Manche Numérique
+
+tracker=http://lxc-p-web-2:8001/omassot/mncheck
+repository=http://lxc-p-web-2:8001/omassot/mncheck
+# End of mandatory metadata
+
+# Recommended items:
+
+# Uncomment the following line and add your changelog:
+# changelog=
+
+# Tags are comma separated with spaces allowed
+tags=python
+
+homepage=http://homepage
+category=Plugins
+icon=icon.png
+# experimental flag
+experimental=True
+
+# deprecated flag (applies to the whole plugin, not just a single version)
+deprecated=False
+

+ 197 - 0
mncheck.py

@@ -0,0 +1,197 @@
+# -*- coding: utf-8 -*-
+"""
+/***************************************************************************
+ MnCheck
+                                 A QGIS plugin
+ Contrôle des données FTTH format MN
+ Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
+                              -------------------
+        begin                : 2018-12-07
+        git sha              : $Format:%H$
+        copyright            : (C) 2018 by Manche Numérique 2019
+        email                : olivier.massot@manchenumerique.fr
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+import os.path
+
+from PyQt5.QtCore import QSettings, QTranslator, qVersion, QCoreApplication
+from PyQt5.QtGui import QIcon
+from PyQt5.QtWidgets import QAction
+
+from ui.dlg_main import DlgMain
+
+from .resources import *
+
+
+# Initialize Qt resources from file resources.py
+# Import the code for the dialog
+class MnCheck:
+    """QGIS Plugin Implementation."""
+
+    def __init__(self, iface):
+        """Constructor.
+
+        :param iface: An interface instance that will be passed to this class
+            which provides the hook by which you can manipulate the QGIS
+            application at run time.
+        :type iface: QgsInterface
+        """
+        # Save reference to the QGIS interface
+        self.iface = iface
+        # initialize plugin directory
+        self.plugin_dir = os.path.dirname(__file__)
+        # initialize locale
+        locale = QSettings().value('locale/userLocale')[0:2]
+        locale_path = os.path.join(
+            self.plugin_dir,
+            'i18n',
+            'MnCheck_{}.qm'.format(locale))
+
+        if os.path.exists(locale_path):
+            self.translator = QTranslator()
+            self.translator.load(locale_path)
+
+            if qVersion() > '4.3.3':
+                QCoreApplication.installTranslator(self.translator)
+
+        # Create the dialog (after translation) and keep reference
+        self.dlg = DlgMain()
+
+        # Declare instance attributes
+        self.actions = []
+        self.menu = self.tr(u'&MnCheck')
+        # TODO: We are going to let the user set this up in a future iteration
+        self.toolbar = self.iface.addToolBar(u'MnCheck')
+        self.toolbar.setObjectName(u'MnCheck')
+
+    # noinspection PyMethodMayBeStatic
+    def tr(self, message):
+        """Get the translation for a string using Qt translation API.
+
+        We implement this ourselves since we do not inherit QObject.
+
+        :param message: String for translation.
+        :type message: str, QString
+
+        :returns: Translated version of message.
+        :rtype: QString
+        """
+        # noinspection PyTypeChecker,PyArgumentList,PyCallByClass
+        return QCoreApplication.translate('MnCheck', message)
+
+
+    def add_action(
+        self,
+        icon_path,
+        text,
+        callback,
+        enabled_flag=True,
+        add_to_menu=True,
+        add_to_toolbar=True,
+        status_tip=None,
+        whats_this=None,
+        parent=None):
+        """Add a toolbar icon to the toolbar.
+
+        :param icon_path: Path to the icon for this action. Can be a resource
+            path (e.g. ':/plugins/foo/bar.png') or a normal file system path.
+        :type icon_path: str
+
+        :param text: Text that should be shown in menu items for this action.
+        :type text: str
+
+        :param callback: Function to be called when the action is triggered.
+        :type callback: function
+
+        :param enabled_flag: A flag indicating if the action should be enabled
+            by default. Defaults to True.
+        :type enabled_flag: bool
+
+        :param add_to_menu: Flag indicating whether the action should also
+            be added to the menu. Defaults to True.
+        :type add_to_menu: bool
+
+        :param add_to_toolbar: Flag indicating whether the action should also
+            be added to the toolbar. Defaults to True.
+        :type add_to_toolbar: bool
+
+        :param status_tip: Optional text to show in a popup when mouse pointer
+            hovers over the action.
+        :type status_tip: str
+
+        :param parent: Parent widget for the new action. Defaults None.
+        :type parent: QWidget
+
+        :param whats_this: Optional text to show in the status bar when the
+            mouse pointer hovers over the action.
+
+        :returns: The action that was created. Note that the action is also
+            added to self.actions list.
+        :rtype: QAction
+        """
+
+        icon = QIcon(icon_path)
+        action = QAction(icon, text, parent)
+        action.triggered.connect(callback)
+        action.setEnabled(enabled_flag)
+
+        if status_tip is not None:
+            action.setStatusTip(status_tip)
+
+        if whats_this is not None:
+            action.setWhatsThis(whats_this)
+
+        if add_to_toolbar:
+            self.toolbar.addAction(action)
+
+        if add_to_menu:
+            self.iface.addPluginToMenu(
+                self.menu,
+                action)
+
+        self.actions.append(action)
+
+        return action
+
+    def initGui(self):
+        """Create the menu entries and toolbar icons inside the QGIS GUI."""
+
+        icon_path = ':/plugins/mncheck/icon.png'
+        self.add_action(
+            icon_path,
+            text=self.tr(u'MnCheck'),
+            callback=self.run,
+            parent=self.iface.mainWindow())
+
+
+    def unload(self):
+        """Removes the plugin menu item and icon from QGIS GUI."""
+        for action in self.actions:
+            self.iface.removePluginMenu(
+                self.tr(u'&MnCheck'),
+                action)
+            self.iface.removeToolBarIcon(action)
+        # remove the toolbar
+        del self.toolbar
+
+
+    def run(self):
+        """Run method that performs all the real work"""
+        # show the dialog
+        self.dlg.show()
+        # Run the dialog event loop
+        result = self.dlg.exec_()
+        # See if OK was pressed
+        if result:
+            # Do something useful here - delete the line containing pass and
+            # substitute with your code.
+            pass

+ 2 - 0
requirements.txt

@@ -0,0 +1,2 @@
+path.py>=11.1.0
+cerberus

+ 0 - 0
schemas/__init__.py


+ 19 - 0
schemas/common.py

@@ -0,0 +1,19 @@
+'''
+
+@author: olivier.massot, 2018
+'''
+from core import mn
+
+
+XMIN, XMAX, YMIN, YMAX = 1341999, 1429750, 8147750, 8294000
+SRID = "3949"
+
+TOLERANCE = 1
+
+with mn.ANTDb_0() as ant_db:
+    INSEE_VALIDES = [row[0] for row in ant_db.read("SELECT CODE_INSEE FROM SIG_REFERENTIEL.ADM_CD50_COM;")]
+
+# # version Postgres:
+# with mn.ANTDb() as ant_db:
+#     INSEE_VALIDES = [row[0] for row in ant_db.read("SELECT code_insee FROM sig_referentiel.admn_cd50_com;")]
+    

+ 3 - 0
schemas/netgeo_1_12_doe/__init__.py

@@ -0,0 +1,3 @@
+
+from schemas.netgeo_1_12_doe.validator import Netgeo112DoeValidator
+validator = Netgeo112DoeValidator

+ 157 - 0
schemas/netgeo_1_12_doe/models.py

@@ -0,0 +1,157 @@
+'''
+
+@author: olivier.massot, 2018
+'''
+from core import gis_
+from core.cerberus_extend import is_int, is_float, \
+    is_modern_french_date, is_multi_int
+from core.validation import BaseGeoModel
+from schemas.common import INSEE_VALIDES, XMIN, YMIN, XMAX, YMAX
+
+
+class Artere(BaseGeoModel):
+    filename = "artere_geo.shp"
+    geom_type = gis_.GEOM_LINE
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'AR_ID_INSE': {'type': 'string', 'empty': False, 'allowed': INSEE_VALIDES}, 
+              'AR_LONG': {'empty': False, 'validator': is_float},
+              'AR_ETAT': {'type': 'string', 'empty': False, 'allowed': ['0', '1', '2', '3', '4']}, 
+              'AR_OCCP': {'type': 'string', 'empty': False, 'allowed': ['0', '1.1', '1.2', '2', '3', '4']}, 
+              'AR_NOEUD_A': {'type': 'string', 'empty': False, 'maxlength': 20}, 
+              'AR_NOEUD_B': {'type': 'string', 'empty': False, 'maxlength': 20}, 
+              'AR_NB_FOUR': {'empty': False, 'validator': is_multi_int}, 
+              'AR_FOU_DIS': {'empty': False, 'validator': is_int}, 
+              'AR_TYPE_FO': {'type': 'string', 'multiallowed': ['PVC', 'PEHD', 'SOUS-TUBAGE PEHD', 'SOUS-TUBAGE  SOUPLE', 'FACADE', 'AERIEN', 'ENCORBELLEMENT', 'AUTRE']}, 
+              'AR_DIAM_FO': {'type': 'string', 'multiallowed': ['10', '14', '18', '25', '28', '32', '40', '45', '60', '80', '150', 'NUL']}, 
+              'AR_PRO_FOU': {'type': 'string', 'multiallowed': ['MANCHE NUMERIQUE', 'COLLECTIVITE', 'ORANGE', 'PRIVE', 'ERDF', 'AUTRE (à préciser)']}, 
+              'AR_PRO_CAB': {'type': 'string', 'empty': False, 'allowed': ['MANCHE NUMERIQUE']}, 
+              'AR_GEST_FO': {'type': 'string', 'multiallowed': ['MANCHE NUMERIQUE', 'MANCHE TELECOM', 'COLLECTIVITE', 'ORANGE', 'MANCHE FIBRE', 'PRIVE', 'ERDF', 'AUTRE (à préciser)', 'NUL']}, 
+              'AR_UTIL_FO': {'type': 'string', 'multiallowed': ['MANCHE NUMERIQUE', 'MANCHE TELECOM', 'COLLECTIVITE', 'ORANGE', 'MANCHE FIBRE', 'PRIVE', 'AUTRE (à préciser)', 'NUL']}, 
+              'AR_DATE_IN': {'empty': False, 'validator': is_modern_french_date}, 
+              'AR_DATE_RE': {'empty': False, 'validator': is_modern_french_date}, 
+              'AR_REF_PLA': {'type': 'string', 'maxlength': 100}, 
+              'AR_SRC_GEO': {'type': 'string', 'maxlength': 50}, 
+              'AR_QLT_GEO': {'type': 'string', 'empty': False, 'allowed': ['A', 'B', 'C']}, 
+              'AR_PRO_MD': {'type': 'string', 'empty': False, 'default': 'MANCHE NUMERIQUE', 'allowed': ['MANCHE NUMERIQUE']}, 
+              'AR_COMMENT': {'type': 'string', 'maxlength': 300, 'empty': True}, 
+              'AR_STATUT': {'type': 'string', 'empty': False, 'allowed': ['APS', 'APD', 'EXE', 'REC']}}
+
+    def __repr__(self):
+        return "Artere {}-{}".format(self.AR_NOEUD_A, self.AR_NOEUD_B)
+
+class Cable(BaseGeoModel):
+    filename = "cable_geo.shp"
+    geom_type = gis_.GEOM_LINE
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'CA_NUMERO': {'type': 'string', 'maxlength': 17}, 
+              'CA_TYPE': {'type': 'string', 'maxlength': 10, 'empty': False, 'allowed': ['AERIEN', 'IMMEUBLE', 'FACADE', 'MIXTE', 'SOUTERRAIN']}, 
+              'CA_ETAT': {'type': 'string', 'maxlength': 1, 'empty': False, 'allowed': ['0', '1', '2', '3', '4']}, 
+              'CA_LONG': {'validator': is_float}, 
+              'CA_EQ_A': {'type': 'string', 'maxlength': 18}, 
+              'CA_EQ_B': {'type': 'string', 'maxlength': 18}, 
+              'CA_DIAMETR': {'empty': False, 'validator': is_float}, 
+              'CA_COULEUR': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['NOIR', 'BLEU', 'BLANC']}, 
+              'CA_TECHNOL': {'type': 'string', 'maxlength': 17, 'empty': False, 'allowed': ['G657A2_M6', 'G657A2_M12']}, 
+              'CA_NB_FO': {'validator': is_int}, 
+              'CA_NB_FO_U': {'empty': False, 'validator': is_int}, 
+              'CA_NB_FO_D': {'empty': False, 'validator': is_int}, 
+              'CA_PRO': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE NUMERIQUE']}, 
+              'CA_GEST': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE FIBRE']}, 
+              'CA_DATE_IN': {'empty': False, 'validator': is_modern_french_date}, 
+              'CA_COMMENT': {'type': 'string', 'maxlength': 300, 'empty': True}, 
+              'CA_STATUT': {'type': 'string', 'maxlength': 14, 'empty': False, 'allowed': ['APS', 'APD', 'EXE', 'REC']}}
+
+    def __repr__(self):
+        return "Cable {}-{}".format(self.CA_EQ_A, self.CA_EQ_B)
+    
+class Equipement(BaseGeoModel):
+    filename = "equipement_passif.shp"
+    geom_type = gis_.GEOM_POINT
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'EQ_NOM': {'type': 'string', 'maxlength': 10, 'contains_any_of': ['PBO', 'BPE', 'BAI']}, 
+              'EQ_NOM_NOE': {'type': 'string', 'maxlength': 30}, 
+              'EQ_ETAT': {'type': 'string', 'maxlength': 1, 'empty': False, 'allowed': ['0', '1', '2', '3', '4']}, 
+              'EQ_OCCP': {'type': 'string', 'maxlength': 3, 'empty': False, 'allowed': ['0', '1.1', '1.2', '2', '3', '4']}, 
+              'EQ_TYPE': {'type': 'string', 'empty': False, 'allowed': ['PBO', 'PBOE', 'BPE', 'BAI']}, 
+              'EQ_TYPE_LQ': {'type': 'string', 'maxlength': 6, 'empty': False, 'allowed': ['PBO', 'BPE JB', 'BPE JD', 'BAIDC', 'BAIOP']}, 
+              'EQ_TYPE_PH': {'type': 'string', 'maxlength': 24, 'empty': False, 'allowed': ['PBO 6', 'PBO 12', 'BPE 12EP', 'BPE 24EP', 'BPE 48EP', 'BPE 72EP', 'BPE 96EP', 'BPE 144EP', 'BPE 288EP', 'BPE 576EP', 'BPE 720EP', 'BAI']}, 
+              'EQ_PRO': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE NUMERIQUE', 'COLLECTIVITE', 'ORANGE', 'PRIVE', 'AUTRE (à préciser)', 'NUL']}, 
+              'EQ_GEST': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE NUMERIQUE', 'MANCHE TELECOM', 'COLLECTIVITE', 'ORANGE', 'MANCHE FIBRE', 'PRIVE', 'AUTRE (à préciser)', 'NUL']}, 
+              'EQ_HAUT': {'empty': False, 'validator': is_float}, 
+              'EQ_DATE_IN': {'empty': False, 'validator': is_modern_french_date}, 
+              'EQ_COMMENT': {'type': 'string', 'maxlength': 300, 'empty': True}, 
+              'EQ_STATUT': {'type': 'string', 'maxlength': 14, 'empty': False, 'allowed': ['APS', 'APD', 'EXE', 'REC']}}
+        
+    def __repr__(self):
+        return "Equipement {}".format(self.EQ_NOM)
+
+class Noeud(BaseGeoModel):
+    filename = "noeud_geo.shp"
+    geom_type = gis_.GEOM_POINT
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'NO_NOM': {'type': 'string', 'maxlength': 30}, 
+              'NO_ID_INSE': {'type': 'string', 'empty': False, 'allowed': INSEE_VALIDES}, 
+              'NO_VOIE': {'type': 'string', 'maxlength': 100}, 
+              'NO_ETAT': {'type': 'string', 'maxlength': 1, 'empty': False, 'allowed': ['0', '1', '2', '3', '4']}, 
+              'NO_OCCP': {'type': 'string', 'maxlength': 3, 'empty': False, 'allowed': ['0', '1.1', '1.2', '2', '3', '4']}, 
+              'NO_TYPE': {'type': 'string', 'maxlength': 3, 'empty': False, 'allowed': ['CHA', 'POT', 'LTE', 'SEM', 'FAC', 'OUV', 'IMM']}, 
+              'NO_TYPE_LQ': {'type': 'string', 'maxlength': 10, 'empty': False, 'allowed': ['CHTIR', 'CHRACC', 'POT', 'NRO', 'PM', 'MIMO', 'FAC', 'OUV', 'IMM']}, 
+              'NO_TYPE_PH': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['CHAMBRE', 'POTEAU', 'ARMOIRE', 'SHELTER', 'BATIMENT', 'SITE MIMO', 'FACADE', 'OUVRAGE', 'IMMEUBLE']}, 
+              'NO_CODE_PH': {'type': 'string', 'maxlength': 20}, 
+              'NO_TECH_PS': {'type': 'string', 'maxlength': 20, 'multiallowed': ['COAX', 'CUT', 'ECL', 'ELEC', 'VP', 'OPT', 'NC']}, 
+              'NO_AMO': {'type': 'string', 'maxlength': 20}, 
+              'NO_PLINOX': {'required':False, 'type': 'string', 'maxlength': 3, 'allowed': ['OUI', 'NON']}, 
+              'NO_X': {'empty': False, 'validator': is_float}, 
+              'NO_Y': {'empty': False, 'validator': is_float}, 
+              'NO_PRO': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE NUMERIQUE', 'COLLECTIVITE', 'ORANGE', 'ERDF', 'PRIVE', 'ENEDIS', 'AUTRE (à préciser)', 'NUL']}, 
+              'NO_GEST': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE NUMERIQUE', 'MANCHE TELECOM', 'COLLECTIVITE', 'ORANGE', 'ERDF', 'ENEDIS', 'MANCHE FIBRE', 'PRIVE', 'AUTRE (à préciser)', 'NUL']}, 
+              'NO_HAUT': {'empty': False, 'validator': is_float}, 
+              'NO_DATE_IN': {'empty': False, 'validator': is_modern_french_date}, 
+              'NO_REF_PLA': {'type': 'string', 'maxlength': 100}, 
+              'NO_SRC_GEO': {'type': 'string', 'maxlength': 50}, 
+              'NO_QLT_GEO': {'type': 'string', 'maxlength': 1, 'empty': False, 'allowed': ['A', 'B', 'C']}, 
+              'NO_PRO_MD': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE NUMERIQUE']}, 
+              'NO_COMMENT': {'type': 'string', 'maxlength': 300, 'empty': True}, 
+              'NO_STATUT': {'type': 'string', 'maxlength': 14, 'empty': False, 'allowed': ['APS', 'APD', 'EXE', 'REC']}}
+
+    def __repr__(self):
+        return "Noeud {}".format(self.NO_NOM)
+    
+class Tranchee(BaseGeoModel):
+    filename = "tranchee_geo.shp"
+    geom_type = gis_.GEOM_LINE
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'TR_ID_INSE': {'type': 'string', 'empty': False, 'allowed': INSEE_VALIDES}, 
+              'TR_VOIE': {'type': 'string', 'maxlength': 200}, 
+              'TR_TYP_IMP': {'type': 'string', 'empty': False, 'allowed': ['ACCOTEMENT STABILISE', 'ACCOTEMENT NON STABILISE', 'CHAUSSEE LOURDE', 'CHAUSSEE LEGERE', 'FOSSE', 'TROTTOIR', 'ESPACE VERT', 'ENCORBELLEMENT']}, 
+              'TR_MOD_POS': {'type': 'string', 'empty': False, 'allowed': ['TRADITIONNEL', 'MICRO TRANCHEE', 'FONCAGE 60', 'FONCAGE 90', 'FONCAGE 120', 'TRANCHEUSE', 'FORAGE URBAIN', 'FORAGE RURAL', 'ENCORBELLEMENT']}, 
+              'TR_LONG': {'empty': False, 'validator': is_float}, 
+              'TR_LARG': {'empty': False, 'validator': is_float}, 
+              'TR_REVET': {'empty':True, 'type': 'string', 'allowed': ['SABLE', 'BICOUCHE', 'ENROBE', 'BETON', 'PAVE', 'TERRAIN NATUREL']}, 
+              'TR_CHARGE': {'empty': False, 'validator': is_float}, 
+              'TR_GRILLAG': {'empty':True, 'validator': is_float}, 
+              'TR_REMBLAI': {'type': 'string'}, 
+              'TR_PLYNOX': {'type': 'string', 'empty': False, 'allowed': ['OUI', 'NON']}, 
+              'TR_PRO_VOI': {'type': 'string', 'empty': False, 'allowed': ['COMMUNE', 'COMMUNAUTE DE COMMUNES', 'DEPARTEMENT', 'ETAT', 'PRIVE']}, 
+              'TR_GEST_VO': {'type': 'string', 'empty': False, 'allowed': ['COMMUNE', 'COMMUNAUTE DE COMMUNES', 'DEPARTEMENT', 'ETAT', 'PRIVE']}, 
+              'TR_SCHEMA': {'maxlength': 100, 'type': 'string'}, 
+              'TR_DATE_IN': {'empty': False, 'validator': is_modern_french_date}, 
+              'TR_SRC_GEO': {'type': 'string', 'maxlength': 50}, 
+              'TR_QLT_GEO': {'type': 'string', 'empty': False, 'allowed': ['A', 'B', 'C']}, 
+              'TR_PRO_MD': {'type': 'string', 'maxlength': 20}, 
+              'TR_COMMENT': {'type': 'string', 'maxlength': 300, 'empty': True}, 
+              'TR_STATUT': {'type': 'string', 'empty': False, 'allowed': ['APS', 'APD', 'EXE', 'REC']}}
+
+    def __repr__(self):
+        return "Tranchee {}".format(self.TR_VOIE)
+    
+class Zapbo(BaseGeoModel):
+    filename = "zapbo_geo.shp"
+    geom_type = gis_.GEOM_POLYGON
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'ID_ZAPBO': {'type': 'string', 'maxlength': 30, 'contains_any_of': ['PBO', 'BPE']}, 
+              'COMMENTAIR': {'type': 'string', 'maxlength': 254, 'empty': True}, 
+              'STATUT': {'type': 'string', 'empty': False, 'allowed': ['APS', 'APD', 'EXE', 'REC']}}
+    
+    def __repr__(self):
+        return "Zapbo {}".format(self.ID_ZAPBO)

+ 28 - 0
schemas/netgeo_1_12_doe/readme.md

@@ -0,0 +1,28 @@
+## Tests effectués
+
+* **Chargement des données**
+
+  * Présence des fichiers attendus: "artere_geo.shp", "cable_geo.shp", "equipement_passif.shp", "noeud_geo.shp", "tranchee_geo.shp","zapbo_geo.shp"
+  * Fichiers au format SHP
+  * Types de géométrie
+
+* **Structure**
+
+  * Les coordonnées des entités sont dans la zone d'emprise du département de la Manche
+  * Les champs attendus sont présents
+  * Les champs obligatoires sont renseignés
+  * Type des données (entiers, décimaux, dates...)
+  * Les champs ne dépassent pas la longueur autorisée
+  * Contrôle des valeurs autorisées
+  
+* **Validation Métier**
+
+  * Contrôle des doubloins dans les codes des noeuds, equipements, zapbo.
+  * Contrôle des cardinalités: correspondance artère/noeuds, cables/equipements, equipement/noeud...etc.
+  * Contrôle des doublons graphiques
+  * Cohérence de la géographie (ex: les noeuds sont situés aux extrémités des artères)
+  * Contrôle de la logique des données (ex: le nombre des fourreaux utilisés n'est pas supérieur au nombre total)
+  * Comparaison avec la base de données (ex: un noeud portant le même code n'existe pas déjà ailleurs sur le territoire)
+  
+
+  

+ 341 - 0
schemas/netgeo_1_12_doe/validator.py

@@ -0,0 +1,341 @@
+'''
+
+@author: olivier.massot, 2018
+'''
+from osgeo import ogr
+
+from core import mn
+from core.gis_ import Feature
+from core.validation import NetgeoValidator, RelationError, \
+    DuplicatedGeom, MissingItem, DimensionError, TechnicalValidationError, \
+    InvalidGeometry, UniqueError, PositionError
+from schemas.common import TOLERANCE
+from schemas.netgeo_1_12_doe.models import Artere, Cable, Equipement, Noeud, \
+    Tranchee, Zapbo
+
+
+class Netgeo112DoeValidator(NetgeoValidator):
+    schema_name = "Netgeo v1.12 DOE"
+    models = [Artere, Cable, Equipement, Noeud, Tranchee, Zapbo]
+    
+    def _technical_validation(self):
+        
+        # construction des index
+        arteres = self.dataset[Artere]
+        cables = self.dataset[Cable]
+        tranchees = self.dataset[Tranchee]
+        
+        noeuds = {}
+        for noeud in self.dataset[Noeud]:
+            if not noeud.NO_NOM in noeuds:
+                noeuds[noeud.NO_NOM] = noeud
+            else:
+                self.log_error(UniqueError("Doublons dans le champs: {}".format(noeud), filename=Noeud.filename, field="NO_NOM"))
+        
+        equipements = {}
+        for equipement in self.dataset[Equipement]:
+            if not equipement.EQ_NOM in equipements:
+                equipements[equipement.EQ_NOM] = equipement
+            else:
+                self.log_error(UniqueError("Doublons dans le champs: {}".format(equipement), filename=Equipement.filename, field="EQ_NOM"))
+                
+        zapbos = {}
+        for zapbo in self.dataset[Zapbo]:
+            if not zapbo.ID_ZAPBO in zapbos:
+                zapbos[zapbo.ID_ZAPBO] = zapbo
+            else:
+                self.log_error(UniqueError("Doublons dans le champs: {}".format(zapbo), filename=Zapbo.filename, field="ID_ZAPBO"))
+        
+        # contrôle de la validité des géométries
+        for artere in arteres:
+            if not artere.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(artere), filename=Artere.filename, field="geom"))
+        for tranchee in tranchees:
+            if not tranchee.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(tranchee), filename=Tranchee.filename, field="geom"))
+        for cable in cables:
+            if not "baguette" in cable.CA_COMMENT.lower() and not cable.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(cable), filename=Cable.filename, field="geom"))
+        for noeud in noeuds.values():
+            if not noeud.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(noeud), filename=Noeud.filename, field="geom"))
+        for equipement in equipements.values():
+            if not equipement.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(equipement), filename=Equipement.filename, field="geom"))
+        for zapbo in zapbos.values():
+            if not zapbo.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(zapbo), filename=Zapbo.filename, field="geom"))
+        
+        # rattachement les noeuds aux artères     
+        for artere in arteres:
+            try:
+                artere.noeud_a = noeuds[artere.AR_NOEUD_A]
+            except KeyError:
+                artere.noeud_a = None
+                self.log_error(RelationError("Le noeud '{}' n'existe pas".format(artere.AR_NOEUD_A), filename=Artere.filename, field="AR_NOEUD_A"))
+                
+            try:
+                artere.noeud_b = noeuds[artere.AR_NOEUD_B]
+            except KeyError:
+                artere.noeud_b = None
+                self.log_error(RelationError("Le noeud '{}' n'existe pas".format(artere.AR_NOEUD_B), filename=Artere.filename, field="AR_NOEUD_A"))
+        
+        # rattachement des equipements aux cables
+        for cable in cables:
+            try:
+                cable.equipement_a = equipements[cable.CA_EQ_A]
+            except KeyError:
+                cable.equipement_a = None
+                self.log_error(RelationError("L'équipement '{}' n'existe pas".format(cable.CA_EQ_A), filename=Cable.filename, field="CA_EQ_A"))
+                
+            try:
+                cable.equipement_b = equipements[cable.CA_EQ_B]
+            except KeyError:
+                cable.equipement_b = None
+                self.log_error(RelationError("L'équipement '{}' n'existe pas".format(cable.CA_EQ_B), filename=Cable.filename, field="CA_EQ_B"))
+
+        # rattachement des equipements aux noeuds
+        for equipement in equipements.values():
+            try:
+                equipement.noeud = noeuds[equipement.EQ_NOM_NOE]
+            except KeyError:
+                equipement.noeud = None
+                self.log_error(RelationError("Le noeud '{}' n'existe pas".format(equipement.EQ_NOM_NOE, equipement.EQ_NOM), filename=Equipement.filename, field="EQ_NOM_NOE"))
+
+        # verifie que tous les equipements sont l'equipement B d'au moins un cable
+        equipements_b = [cable.CA_EQ_B for cable in cables]
+        for eq_id in equipements:
+            if equipements[eq_id].EQ_TYPE == "BAI":
+                continue
+            if not eq_id in equipements_b:
+                self.log_error(RelationError("L'equipement '{}' n'est l'équipement B d'aucun cable".format(eq_id), filename=Equipement.filename, field="EQ_NOM"))
+
+        # controle des doublons graphiques
+        for i, tranchee in enumerate(tranchees):
+            for other in tranchees[i+1:]:
+                if tranchee.geom == other.geom:
+                    self.log_error(DuplicatedGeom("Une entité graphique est dupliquée".format(tranchee), filename=Tranchee.filename, field="geom"))
+                    
+        for i, artere in enumerate(arteres):
+            for other in arteres[i+1:]:
+                if artere.geom == other.geom:
+                    self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(artere), filename=Artere.filename, field="geom"))
+
+        for i, cable in enumerate(cables):
+            for other in cables[i+1:]:
+                if cable.geom == other.geom and cable.CA_EQ_A == other.CA_EQ_A and cable.CA_EQ_B == other.CA_EQ_B:
+                    self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(cable), filename=Cable.filename, field="geom"))
+        
+        ls_noeuds = list(noeuds.values())
+        for i, noeud in enumerate(ls_noeuds):
+            for other in ls_noeuds[i+1:]:
+                if noeud.geom == other.geom:
+                    self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(noeud), filename=Noeud.filename, field="geom"))
+        del ls_noeuds
+        
+        ls_zapbos = list(zapbos.values())
+        for i, zapbo in enumerate(ls_zapbos):
+            for other in ls_zapbos[i+1:]:
+                if zapbo.geom == other.geom:
+                    self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(zapbo), filename=Zapbo.filename, field="geom"))
+        del ls_zapbos
+           
+        # Arteres: comparer la géométrie à celle des noeuds
+        for artere in arteres:
+            if not artere.noeud_a or not artere.noeud_b:
+                continue
+            
+            buffer_a, buffer_b = artere.points[0].Buffer(TOLERANCE), artere.points[-1].Buffer(TOLERANCE)
+            
+            if not (buffer_a.Contains(artere.noeud_a.points[0]) and buffer_b.Contains(artere.noeud_b.points[0])) \
+               and not (buffer_a.Contains(artere.noeud_b.points[0]) and buffer_b.Contains(artere.noeud_a.points[0])):
+
+                self.log_error(MissingItem("Pas de noeud aux coordonnées attendues ('{}')".format(artere), filename=Artere.filename, field="geom"))
+        
+        
+        # Cables: comparer la géométrie à celle des equipements (on utilise en fait la geom du noeud correspondant à l'équipement)
+        for cable in cables:
+            if not cable.equipement_a or not cable.equipement_b or not cable.valid or not cable.equipement_a.noeud or not cable.equipement_b.noeud:
+                continue
+            
+            buffer_a, buffer_b = cable.points[0].Buffer(TOLERANCE), cable.points[-1].Buffer(TOLERANCE)
+            
+            if not (buffer_a.Contains(cable.equipement_a.noeud.points[0]) and buffer_b.Contains(cable.equipement_b.noeud.points[0])) \
+               and not (buffer_a.Contains(cable.equipement_b.noeud.points[0]) and buffer_b.Contains(cable.equipement_a.noeud.points[0])):
+            
+                self.log_error(MissingItem("Pas d'equipement aux coordonnées attendues ('{}')".format(cable), filename=Cable.filename, field="geom"))
+            
+        del buffer_a, buffer_b 
+        
+        # Verifie que chaque tranchée a au moins une artère
+        arteres_emprise = Feature.buffered_union(arteres, TOLERANCE)
+        
+        for tranchee in tranchees:
+            if not arteres_emprise.Contains(tranchee.geom):
+                self.log_error(MissingItem("Tranchée sans artère ('{}')".format(tranchee), filename=Tranchee.filename, field="-"))
+        
+        
+        # Verifie que chaque cable a au moins une artère (sauf si commentaire contient 'baguette')
+        for cable in cables:
+            if "baguette" in cable.CA_COMMENT.lower() or not cable.valid:
+                continue
+            if not arteres_emprise.Contains(cable.geom):
+                self.log_error(MissingItem("Cable sans artère ('{}')".format(cable), filename=Cable.filename, field="-"))
+        
+        del arteres_emprise
+        
+        # Verifie que chaque artère a au moins un cable (sauf si commentaire contient un de ces mots 'racco client adductio attente bus 'sans cable'')
+        cables_emprise = Feature.buffered_union(cables, TOLERANCE)
+        
+        for artere in arteres:
+            if any(x in artere.AR_COMMENT.lower() for x in ['racco','client','adductio','attente','bus','sans cable']):
+                continue
+            if not cables_emprise.Contains(artere.geom):
+                self.log_error(MissingItem("Artère sans cable ('{}')".format(artere), filename=Artere.filename, field="-"))
+                
+        del cables_emprise
+                
+        # Contrôle des dimensions logiques
+        for artere in arteres:
+            try:
+                if not int(artere.AR_FOU_DIS) <= int(artere.AR_NB_FOUR):
+                    self.log_error(DimensionError("Le nombre de fourreaux disponibles doit être inférieur au nombre total ('{}')".format(artere), filename=Artere.filename, field="AR_FOU_DIS"))
+            except (TypeError, ValueError):
+                pass
+        
+        for cable in cables:
+            try:
+                if not int(cable.CA_NB_FO_U) <= int(cable.CA_NB_FO):
+                    self.log_error(DimensionError("Le nombre de fourreaux utilisés doit être inférieur au nombre total ('{}')".format(cable), filename=Cable.filename, field="CA_NB_FO_U"))
+                if not int(cable.CA_NB_FO_D) <= int(cable.CA_NB_FO):
+                    self.log_error(DimensionError("Le nombre de fourreaux disponibles doit être inférieur au nombre total ('{}')".format(cable), filename=Cable.filename, field="CA_NB_FO_D"))
+            except (TypeError, ValueError):
+                pass
+        
+        ant_db = mn.ANTDb_0()
+        ant_db.execute("alter session set NLS_NUMERIC_CHARACTERS = '.,';") # definit le separateur decimal sur '.'
+        
+        # Toutes les zapbo contiennent au moins une prise
+        for zapbo in zapbos.values():
+            
+            if len(zapbo.points) >= 499:
+                # passe l'erreur provoquée par la limite au nombre d'arguments en SQL
+                zapbo.nb_prises = None
+                continue
+            
+            sql = """Select SUM(NB_PRISE) AS NB_PRISES FROM SIG_ANT.FTTH_MN_PRISE_LOT z 
+                       WHERE SDO_INSIDE(z.GEOMETRY,
+                                      SDO_GEOMETRY(2003, 3949, SDO_POINT_TYPE(null,null,null), SDO_ELEM_INFO_ARRAY(1,1003,1),  SDO_ORDINATE_ARRAY({}))
+                                         )='TRUE';""".format(", ".join(["{},{}".format(p.GetX(), p.GetY()) for p in zapbo.points]))
+            nb_prises = ant_db.first(sql).NB_PRISES         
+            zapbo.nb_prises = int(nb_prises) if nb_prises is not None else 0
+            
+            if not zapbo.nb_prises:
+                self.log_error(MissingItem("La Zapbo ne contient aucune prise: {}".format(zapbo), filename=Zapbo.filename, field="-"))
+        
+
+        # Toutes les prises de la ou les ZAPM impactées sont dans une zapbo
+        zapms = {}
+        # > on déduit la liste des zapms à partir de la position des zapbos
+        for zapbo in zapbos.values():
+            centre = zapbo.geom.Centroid()
+            zapm = ant_db.first("""SELECT z.ID_ZAPM 
+                                  FROM SIG_ANT.FTTH_MN_ZAPM z
+                                  WHERE sdo_contains(z.GEOMETRY, 
+                                                     SDO_GEOMETRY(2001, 3949, SDO_POINT_TYPE({}, {}, NULL), NULL, NULL)) = 'TRUE'
+                               """.format(centre.GetX(), centre.GetY()))
+            try:
+                zapms[zapm.ID_ZAPM].append(zapbo)
+            except KeyError:
+                zapms[zapm.ID_ZAPM] = [zapbo]
+        
+        for id_zapm in zapms:
+            zapm_couverture = Feature.union(zapms[id_zapm])
+            for prise in ant_db.read("""SELECT t.X AS x, t.Y AS y
+                                        FROM SIG_ANT.FTTH_MN_PRISE_LOT z, 
+                                        TABLE(SDO_UTIL.GETVERTICES(z.GEOMETRY)) t 
+                                        WHERE T_ETAT<>'OBSOLETE' AND ID_ZAPM_PARTIELLE='{}';""".format(id_zapm)):
+                point = ogr.Geometry(ogr.wkbPoint)
+                point.AddPoint(prise.x, prise.y)
+                if not zapm_couverture.Contains(point):
+                    self.log_error(MissingItem("Certaines prises de la ZAPM ne sont pas comprises dans une ZAPBO: {}".format(id_zapm), filename=Zapbo.filename, field="-"))
+        
+        # Verifier que chaque equipement de type PBO est contenu dans une zapbo, et que le nom de la zapbo contient le nom de l'equipement
+        
+        for equipement in equipements.values():
+            if not equipement.EQ_TYPE == "PBO":
+                continue
+            
+            #zapbos englobant l'equipement
+            candidates = []
+            for zapbo in zapbos.values():
+                if zapbo.geom.Contains(equipement.geom):
+                    candidates.append(zapbo)
+                    
+            # le pbo doit être contenu dans une zapbo
+            if not candidates:
+                self.log_error(MissingItem("Le PBO n'est contenu dans aucune ZAPBO: {}".format(equipement), filename=Equipement.filename, field="geom"))
+                continue
+            
+            # On se base sur le nom pour trouver la zapbo correspondante
+            try:
+                equipement.zapbo = next((z for z in candidates if equipement.EQ_NOM in z.ID_ZAPBO))
+            except StopIteration:
+                self.log_error(MissingItem("Le nom du PBO ne coincide avec le nom d'aucune des ZAPBO qui le contient: {}".format(equipement), filename=Equipement.filename, field="EQ_NOM"))
+                break
+            
+            if equipement.zapbo.nb_prises is None:
+                equipement.zapbo.nb_prises = 0
+            
+            # Controle du dimensionnement des PBO
+            if equipement.EQ_TYPE_PH == 'PBO 6' and not equipement.zapbo.nb_prises < 6:
+                self.log_error(DimensionError("Le PBO 6 contient plus de 5 prises: {}".format(equipement), filename=Equipement.filename, field="-"))
+        
+            if equipement.EQ_TYPE_PH == 'PBO 12' and not equipement.zapbo.nb_prises >= 6 and equipement.zapbo.nb_prises <= 8:
+                self.log_error(DimensionError("Le PBO 12 contient mois de 6 prises ou plus de 8 prises: {}".format(equipement), filename=Equipement.filename, field="-"))
+        
+            if equipement.zapbo.STATUT == "REC" and not equipement.EQ_STATUT == "REC":
+                self.log_error(TechnicalValidationError("Le statut du PBO n'est pas cohérent avec le statut de sa ZAPBO: {}".format(equipement), filename=Equipement.filename, field="-"))
+        
+            if equipement.EQ_STATUT == "REC" and not equipement.zapbo.STATUT == "REC" and not equipement.zapbo.ID_ZAPBO[:4].lower() == "att_":
+                self.log_error(TechnicalValidationError("Le statut du PBO n'est pas cohérent avec le statut de sa ZAPBO: {}".format(equipement), filename=Equipement.filename, field="-"))
+        
+        # Contrôler dans la base si des éléments portant ces codes existent à des emplacements différents
+        for noeud in noeuds.values():
+            sql = """SELECT z.NO_NOM, SDO_GEOM.SDO_DISTANCE(z.GEOMETRY, SDO_GEOMETRY(2001, 3949, SDO_POINT_TYPE({}, {}, NULL), NULL, NULL),0.005) AS DIST 
+                     FROM SIG_ANT.FTTH_MN_GR_NOEUD_GEO z 
+                     WHERE z.NO_NOM='{}';""".format(noeud.geom.GetX(), noeud.geom.GetY(), noeud.NO_NOM)
+            existing = ant_db.first(sql)
+            if existing:
+                if existing.DIST > TOLERANCE and existing.DIST < 20:
+                    self.log_error(PositionError("La position du noeud ne correspond pas à l'existant: {}".format(noeud), filename=Noeud.filename, field="geom"))
+                elif existing.DIST > 20:
+                    self.log_error(DuplicatedGeom("Un noeud portant ce nom existe déjà ailleurs sur le territoire: {}".format(noeud), filename=Noeud.filename, field="NO_NOM"))
+        
+        for zapbo in zapbos.values():
+            sql = """SELECT z.ID_ZAPBO, SDO_GEOM.SDO_DISTANCE(SDO_GEOM.SDO_CENTROID(z.GEOMETRY,0.005), SDO_GEOMETRY(2001, 3949, SDO_POINT_TYPE({}, {}, NULL), NULL, NULL),0.005) AS DIST 
+                     FROM SIG_ANT.FTTH_MN_GR_ZAPBO_GEO z 
+                     WHERE z.ID_ZAPBO='{}';""".format(zapbo.geom.Centroid().GetX(), zapbo.geom.Centroid().GetY(), zapbo.ID_ZAPBO)
+            existing = ant_db.first(sql)
+            if existing:
+                if existing.DIST > TOLERANCE and existing.DIST < 20:
+                    self.log_error(PositionError("La position de la ZAPBO ne correspond pas à l'existant: {}".format(zapbo), filename=Zapbo.filename, field="geom"))
+                elif existing.DIST > 20:
+                    self.log_error(DuplicatedGeom("Une ZAPBO portant ce nom existe déjà ailleurs sur le territoire: {}".format(zapbo), filename=Zapbo.filename, field="ID_ZAPBO"))
+
+        # Contrôle si un equipement portant ce nom existe, mais associé à un noeud différent
+        for equipement in equipements.values():
+            sql = """SELECT z.EQ_NOM, z.EQ_NOM_NOEUD
+                     FROM SIG_ANT.FTTH_MN_GR_EQ_PASSIF z 
+                     WHERE z.EQ_NOM='{}';""".format(equipement.EQ_NOM)
+            existing = ant_db.first(sql)
+            if existing and existing.EQ_NOM_NOEUD != equipement.EQ_NOM_NOE:
+                self.log_error(DuplicatedGeom("Un équipement portant ce nom ({}) existe déjà et est associé à un noeud différent ({})".format(equipement.NO_NOM, existing.EQ_NOM_NOEUD), filename=Noeud.filename, field="geom"))
+        
+        
+if __name__ == "__main__":
+    from core.constants import MAIN
+    subject = MAIN / "work" / "SOGETREL_026AP0_REC_181001_OK"
+    report = Netgeo112DoeValidator.submit(subject)
+    print(report)
+    

+ 4 - 0
schemas/netgeo_2_2_doe/__init__.py

@@ -0,0 +1,4 @@
+from schemas.netgeo_2_2_doe.validator import Netgeo22DoeValidator
+
+
+validator = Netgeo22DoeValidator

+ 189 - 0
schemas/netgeo_2_2_doe/models.py

@@ -0,0 +1,189 @@
+'''
+
+@author: olivier.massot, 2018
+'''
+from core import gis_
+from core.cerberus_extend import is_int, is_float, \
+    is_modern_french_date, is_multi_int
+from core.validation import BaseGeoModel
+from schemas.common import INSEE_VALIDES, XMIN, YMIN, XMAX, YMAX
+
+STATUTS = ['EN ETUDE', 'EN REALISATION', 'EN SERVICE', 'HORS SERVICE']
+
+class Artere(BaseGeoModel):
+    filename = "artere_geo.shp"
+    geom_type = gis_.GEOM_LINE
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'AR_CODE': {'type': 'string', 'maxlength': 26}, 
+              'AR_NOM': {'type': 'string', 'maxlength': 26}, 
+              'AR_ID_INSE': {'type': 'string', 'empty': False, 'allowed': INSEE_VALIDES}, 
+              'AR_LONG': {'empty': False, 'validator': is_float},
+              'AR_ETAT': {'type': 'string', 'empty': False, 'allowed': ['0', '1', '2', '3', '4']}, 
+              'AR_OCCP': {'type': 'string', 'empty': False, 'allowed': ['0', '1.1', '1.2', '2', '3', '4']}, 
+              'AR_NOEUD_A': {'type': 'string', 'empty': False, 'maxlength': 20}, 
+              'AR_NOEUD_B': {'type': 'string', 'empty': False, 'maxlength': 20}, 
+              'AR_NB_FOUR': {'empty': False, 'validator': is_multi_int}, 
+              'AR_FOU_DIS': {'empty': False, 'validator': is_int}, 
+              'AR_TYPE_FO': {'type': 'string', 'multiallowed': ['PVC', 'PEHD', 'SOUS-TUBAGE PEHD', 'SOUS-TUBAGE  SOUPLE', 'FACADE', 'AERIEN', 'ENCORBELLEMENT', 'AUTRE']}, 
+              'AR_TYFO_AI': {'type': 'string', 'multiallowed': ['PVC', 'PEH', 'TUB', 'FAC', 'ENC', 'APP']}, 
+              'AR_DIAM_FO': {'type': 'string', 'multiallowed': ['10', '14', '18', '25', '28', '32', '40', '45', '60', '80', '150', 'NUL']}, 
+              'AR_PRO_FOU': {'type': 'string', 'multiallowed': ['MANCHE NUMERIQUE', 'COLLECTIVITE', 'ORANGE', 'PRIVE', 'ERDF', 'AUTRE (à préciser)']}, 
+              'AR_FABRICANT': {'type': 'string', 'empty': False, 'maxlength': 100},
+              'AR_REF_FABRICANT': {'type': 'string', 'empty': False, 'maxlength': 100},
+              'AR_COULEUR': {'type': 'string', 'empty': False, 'maxlength': 20},
+              'AR_AIGUILLEE': {'type': 'string', 'empty': False, 'maxlength': 3, 'allowed': ['OUI', 'NON']},
+              'AR_NB_CABLES': {'empty': False, 'validator': is_int},
+              'AR_PRO_CAB': {'type': 'string', 'empty': False, 'allowed': ['MANCHE NUMERIQUE']}, 
+              'AR_GEST_FO': {'type': 'string', 'multiallowed': ['MANCHE NUMERIQUE', 'MANCHE TELECOM', 'COLLECTIVITE', 'ORANGE', 'MANCHE FIBRE', 'PRIVE', 'ERDF', 'AUTRE (à préciser)', 'NUL']}, 
+              'AR_UTIL_FO': {'type': 'string', 'multiallowed': ['MANCHE NUMERIQUE', 'MANCHE TELECOM', 'COLLECTIVITE', 'ORANGE', 'MANCHE FIBRE', 'PRIVE', 'AUTRE (à préciser)', 'NUL']}, 
+              'AR_DATE_IN': {'empty': False, 'validator': is_modern_french_date}, 
+              'AR_DATE_RE': {'empty': False, 'validator': is_modern_french_date}, 
+              'AR_REF_PLA': {'type': 'string', 'maxlength': 100}, 
+              'AR_SRC_GEO': {'type': 'string', 'maxlength': 50}, 
+              'AR_QLT_GEO': {'type': 'string', 'empty': False, 'allowed': ['A', 'B', 'C']}, 
+              'AR_PRO_MD': {'type': 'string', 'empty': False, 'default': 'MANCHE NUMERIQUE', 'allowed': ['MANCHE NUMERIQUE']}, 
+              'AR_COMMENT': {'type': 'string', 'maxlength': 300, 'empty': True}, 
+              'AR_STATUT': {'type': 'string', 'empty': False, 'allowed': STATUTS}}
+
+    def __repr__(self):
+        return "Artere {}".format(self.AR_CODE)
+
+class Cable(BaseGeoModel):
+    filename = "cable_geo.shp"
+    geom_type = gis_.GEOM_LINE
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'CA_CODE': {'type': 'string', 'maxlength': 18}, 
+              'CA_NOM': {'type': 'string', 'maxlength': 18}, 
+              'CA_NUMERO': {'type': 'string', 'maxlength': 17},  
+              'CA_EMPRISE': {'type': 'string', 'maxlength': 10}, 
+              'CA_FAB': {'type': 'string', 'maxlength': 100}, 
+              'CA_REFFAB': {'type': 'string', 'maxlength': 100}, 
+              'CA_MODELE': {'type': 'string', 'maxlength': 10}, 
+              'CA_TYPE': {'type': 'string', 'maxlength': 10, 'empty': False, 'allowed': ['AERIEN', 'IMMEUBLE', 'FACADE', 'MIXTE', 'SOUTERRAIN']},
+              'CA_TYPFCT': {'type': 'string', 'maxlength': 3, 'empty': False, 'allowed': ['CDI', 'CTR', 'CBM', 'RAC', 'CBO']},
+              'CA_ETAT': {'type': 'string', 'maxlength': 1, 'empty': False, 'allowed': ['0', '1', '2', '3', '4']}, 
+              'CA_LONG': {'validator': is_float}, 
+              'CA_EQ_A': {'type': 'string', 'maxlength': 18}, 
+              'CA_EQ_B': {'type': 'string', 'maxlength': 18}, 
+              'CA_DIAMETR': {'empty': False, 'validator': is_float}, 
+              'CA_COULEUR': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['NOIR', 'BLEU', 'BLANC']}, 
+              'CA_TECHNOL': {'type': 'string', 'maxlength': 17, 'empty': False, 'allowed': ['G657A2_M6', 'G657A2_M12']}, 
+              'CA_NB_FO': {'validator': is_int}, 
+              'CA_NB_FO_U': {'empty': False, 'validator': is_int}, 
+              'CA_NB_FO_D': {'empty': False, 'validator': is_int}, 
+              'CA_PRO': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE NUMERIQUE']}, 
+              'CA_GEST': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE FIBRE']}, 
+              'CA_DATE_IN': {'empty': False, 'validator': is_modern_french_date}, 
+              'CA_COMMENT': {'type': 'string', 'maxlength': 300, 'empty': True}, 
+              'CA_STATUT': {'type': 'string', 'empty': False, 'allowed': STATUTS}}
+
+    def __repr__(self):
+        return "Cable {}-{}".format(self.CA_EQ_A, self.CA_EQ_B)
+    
+class Equipement(BaseGeoModel):
+    filename = "equipement_passif.shp"
+    geom_type = gis_.GEOM_POINT
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'EQ_CODE': {'type': 'string', 'maxlength': 18}, 
+              'EQ_NOM': {'type': 'string', 'maxlength': 10, 'contains_any_of': ['PBO', 'BPE', 'BAI']}, 
+              'EQ_NOM_NOE': {'type': 'string', 'maxlength': 30}, 
+              'EQ_REF': {'type': 'string', 'maxlength': 100}, 
+              'EQ_EMPRISE': {'type': 'string', 'maxlength': 7}, 
+              'EQ_FABR': {'type': 'string', 'maxlength': 100}, 
+              'EQ_CAPFO': {'empty': False, 'validator': is_int}, 
+              'EQ_NBMXEQ': {'empty': False, 'validator': is_int}, 
+              'EQ_NBCAB': {'empty': False, 'validator': is_int}, 
+              'EQ_DIMENS': {'type': 'string', 'maxlength': 50}, 
+              'EQ_TYPEQ': {'type': 'string', 'maxlength': 100}, 
+              'EQ_ETAT': {'type': 'string', 'empty': False, 'allowed': ['0', '1', '2', '3', '4']}, 
+              'EQ_OCCP': {'type': 'string', 'empty': False, 'allowed': ['0', '1.1', '1.2', '2', '3', '4']}, 
+              'EQ_TYPE': {'type': 'string', 'empty': False, 'allowed': ['PBO', 'PBOE', 'BPE', 'BAI']},
+              'EQ_TYPSTRC': {'type': 'string', 'empty': False, 'allowed': ['CHAMBRE', 'AERIEN', 'FACADE', 'COLONNE MONTANTE', 'PIED IMMEUBLE', 'DTIO']}, 
+              'EQ_TYPE_LQ': {'type': 'string', 'empty': False, 'allowed': ['PBO', 'BPE JB', 'BPE JD', 'BAIDC', 'BAIOP']}, 
+              'EQ_TYPE_PH': {'type': 'string', 'empty': False, 'allowed': ['PBO 6', 'PBO 12', 'BPE 12EP', 'BPE 24EP', 'BPE 48EP', 'BPE 72EP', 'BPE 96EP', 'BPE 144EP', 'BPE 288EP', 'BPE 576EP', 'BPE 720EP', 'BAI']}, 
+              'EQ_PRO': {'type': 'string', 'empty': False, 'allowed': ['MANCHE NUMERIQUE', 'COLLECTIVITE', 'ORANGE', 'PRIVE', 'AUTRE (à préciser)', 'NUL']}, 
+              'EQ_GEST': {'type': 'string', 'empty': False, 'allowed': ['MANCHE NUMERIQUE', 'MANCHE TELECOM', 'COLLECTIVITE', 'ORANGE', 'MANCHE FIBRE', 'PRIVE', 'AUTRE (à préciser)', 'NUL']}, 
+              'EQ_HAUT': {'empty': False, 'validator': is_float}, 
+              'EQ_DATE_IN': {'empty': False, 'validator': is_modern_french_date}, 
+              'EQ_COMMENT': {'type': 'string', 'maxlength': 300, 'empty': True}, 
+              'EQ_STATUT': {'type': 'string', 'empty': False, 'allowed': STATUTS}}
+        
+    def __repr__(self):
+        return "Equipement {}".format(self.EQ_NOM)
+
+class Noeud(BaseGeoModel):
+    filename = "noeud_geo.shp"
+    geom_type = gis_.GEOM_POINT
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'NO_CODE': {'type': 'string', 'maxlength': 18}, 
+              'NO_NOM': {'type': 'string', 'maxlength': 30}, 
+              'NO_ID_INSE': {'type': 'string', 'empty': False, 'allowed': INSEE_VALIDES}, 
+              'NO_VOIE': {'type': 'string', 'maxlength': 100}, 
+              'NO_EMPRISE': {'type': 'string', 'maxlength': 10}, 
+              'NO_ETAT': {'type': 'string', 'empty': False, 'allowed': ['0', '1', '2', '3', '4']}, 
+              'NO_OCCP': {'type': 'string', 'empty': False, 'allowed': ['0', '1.1', '1.2', '2', '3', '4']}, 
+              'NO_TYPE': {'type': 'string', 'empty': False, 'allowed': ['CHA', 'POT', 'LTE', 'SEM', 'FAC', 'OUV', 'IMM']}, 
+              'NO_TYPFCT': {'type': 'string', 'empty': False, 'allowed': ['INTERCONNEXION', 'SATELLITE', 'PASSAGE', 'REGARD', 'INDETERMINE']}, 
+              'NO_TYPE_LQ': {'type': 'string', 'empty': False, 'allowed': ['CHTIR', 'CHRACC', 'POT', 'NRO', 'PM', 'MIMO', 'FAC', 'OUV', 'IMM']}, 
+              'NO_TYPE_PH': {'type': 'string', 'empty': False, 'allowed': ['CHAMBRE', 'POTEAU', 'ARMOIRE', 'SHELTER', 'BATIMENT', 'SITE MIMO', 'FACADE', 'OUVRAGE', 'IMMEUBLE']}, 
+              'NO_CODE_PH': {'type': 'string', 'maxlength': 20}, 
+              'NO_TECH_PS': {'type': 'string', 'maxlength': 20, 'multiallowed': ['COAX', 'CUT', 'ECL', 'ELEC', 'VP', 'OPT', 'NC']}, 
+              'NO_AMO': {'type': 'string', 'maxlength': 20}, 
+              'NO_PLINOX': {'required':False, 'type': 'string', 'maxlength': 3, 'allowed': ['OUI', 'NON']}, 
+              'NO_X': {'empty': False, 'validator': is_float}, 
+              'NO_Y': {'empty': False, 'validator': is_float}, 
+              'NO_PRO': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE NUMERIQUE', 'COLLECTIVITE', 'ORANGE', 'ERDF', 'PRIVE', 'ENEDIS', 'AUTRE (à préciser)', 'NUL']}, 
+              'NO_GEST': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE NUMERIQUE', 'MANCHE TELECOM', 'COLLECTIVITE', 'ORANGE', 'ERDF', 'ENEDIS', 'MANCHE FIBRE', 'PRIVE', 'AUTRE (à préciser)', 'NUL']}, 
+              'NO_HAUT': {'empty': False, 'validator': is_float}, 
+              'NO_DATE_IN': {'empty': False, 'validator': is_modern_french_date}, 
+              'NO_REF_PLA': {'type': 'string', 'maxlength': 100}, 
+              'NO_SRC_GEO': {'type': 'string', 'maxlength': 50}, 
+              'NO_QLT_GEO': {'type': 'string', 'maxlength': 1, 'empty': False, 'allowed': ['A', 'B', 'C']}, 
+              'NO_PRO_MD': {'type': 'string', 'maxlength': 20, 'empty': False, 'allowed': ['MANCHE NUMERIQUE']}, 
+              'NO_COMMENT': {'type': 'string', 'maxlength': 300, 'empty': True}, 
+              'NO_STATUT': {'type': 'string', 'empty': False, 'allowed': STATUTS}}
+
+    def __repr__(self):
+        return "Noeud {}".format(self.NO_NOM)
+    
+class Tranchee(BaseGeoModel):
+    filename = "tranchee_geo.shp"
+    geom_type = gis_.GEOM_LINE
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'TR_CODE': {'type': 'string', 'maxlength': 23}, 
+              'TR_NOM': {'type': 'string', 'maxlength': 23}, 
+              'TR_ID_INSE': {'type': 'string', 'empty': False, 'allowed': INSEE_VALIDES}, 
+              'TR_VOIE': {'type': 'string', 'maxlength': 200}, 
+              'TR_TYP_IMP': {'type': 'string', 'empty': False, 'allowed': ['ACCOTEMENT STABILISE', 'ACCOTEMENT NON STABILISE', 'CHAUSSEE LOURDE', 'CHAUSSEE LEGERE', 'FOSSE', 'TROTTOIR', 'ESPACE VERT', 'ENCORBELLEMENT']}, 
+              'TR_MOD_POS': {'type': 'string', 'empty': False, 'allowed': ['TRADITIONNEL', 'MICRO TRANCHEE', 'FONCAGE 60', 'FONCAGE 90', 'FONCAGE 120', 'TRANCHEUSE', 'FORAGE URBAIN', 'FORAGE RURAL', 'ENCORBELLEMENT']}, 
+              'TR_MPOS_AI': {'type': 'string', 'empty': False, 'allowed': ['TRA', 'ALL', 'FONCAGE 60', 'FON', 'FOR', 'ENC']}, 
+              'TR_LONG': {'empty': False, 'validator': is_float}, 
+              'TR_LARG': {'empty': False, 'validator': is_float}, 
+              'TR_REVET': {'empty':True, 'type': 'string', 'allowed': ['SABLE', 'BICOUCHE', 'ENROBE', 'BETON', 'PAVE', 'TERRAIN NATUREL']}, 
+              'TR_CHARGE': {'empty': False, 'validator': is_float}, 
+              'TR_GRILLAG': {'empty':True, 'validator': is_float}, 
+              'TR_REMBLAI': {'type': 'string'}, 
+              'TR_PLYNOX': {'type': 'string', 'empty': False, 'allowed': ['OUI', 'NON']}, 
+              'TR_PRO_VOI': {'type': 'string', 'empty': False, 'allowed': ['COMMUNE', 'COMMUNAUTE DE COMMUNES', 'DEPARTEMENT', 'ETAT', 'PRIVE']}, 
+              'TR_GEST_VO': {'type': 'string', 'empty': False, 'allowed': ['COMMUNE', 'COMMUNAUTE DE COMMUNES', 'DEPARTEMENT', 'ETAT', 'PRIVE']}, 
+              'TR_SCHEMA': {'maxlength': 100, 'type': 'string'}, 
+              'TR_DATE_IN': {'empty': False, 'validator': is_modern_french_date}, 
+              'TR_SRC_GEO': {'type': 'string', 'maxlength': 50}, 
+              'TR_QLT_GEO': {'type': 'string', 'empty': False, 'allowed': ['A', 'B', 'C']}, 
+              'TR_PRO_MD': {'type': 'string', 'maxlength': 20}, 
+              'TR_COMMENT': {'type': 'string', 'maxlength': 300, 'empty': True}, 
+              'TR_STATUT': {'type': 'string', 'empty': False, 'allowed': STATUTS}}
+
+    def __repr__(self):
+        return "Tranchee {}".format(self.TR_VOIE)
+    
+class Zapbo(BaseGeoModel):
+    filename = "zapbo_geo.shp"
+    geom_type = gis_.GEOM_POLYGON
+    bounding_box = (XMIN,YMIN,XMAX,YMAX)
+    schema = {'ID_ZAPBO': {'type': 'string', 'maxlength': 30, 'contains_any_of': ['PBO', 'BPE']}, 
+              'COMMENTAIR': {'type': 'string', 'maxlength': 254, 'empty': True}, 
+              'STATUT': {'type': 'string', 'empty': False, 'allowed': STATUTS}}
+    
+    def __repr__(self):
+        return "Zapbo {}".format(self.ID_ZAPBO)

+ 343 - 0
schemas/netgeo_2_2_doe/validator.py

@@ -0,0 +1,343 @@
+'''
+
+@author: olivier.massot, 2018
+'''
+from osgeo import ogr
+import pypyodbc
+
+from core import mn
+from core.gis_ import Feature
+from core.validation import NetgeoValidator, RelationError, UniqueError, \
+    InvalidGeometry, DuplicatedGeom, MissingItem, DimensionError, \
+    TechnicalValidationError, PositionError
+from schemas.common import TOLERANCE
+from schemas.netgeo_2_2_doe.models import Artere, Cable, Equipement, Noeud, \
+    Tranchee, Zapbo
+
+
+class Netgeo22DoeValidator(NetgeoValidator):
+    schema_name = "Netgeo v2.2 DOE"
+    models = [Artere, Cable, Equipement, Noeud, Tranchee, Zapbo]
+    
+    def _technical_validation(self):
+        
+        # construction des index
+        arteres = self.dataset[Artere]
+        cables = self.dataset[Cable]
+        tranchees = self.dataset[Tranchee]
+        
+        noeuds = {}
+        for noeud in self.dataset[Noeud]:
+            if not noeud.NO_NOM in noeuds:
+                noeuds[noeud.NO_NOM] = noeud
+            else:
+                self.log_error(UniqueError("Doublons dans le champs: {}".format(noeud), filename=Noeud.filename, field="NO_NOM"))
+        
+        equipements = {}
+        for equipement in self.dataset[Equipement]:
+            if not equipement.EQ_NOM in equipements:
+                equipements[equipement.EQ_NOM] = equipement
+            else:
+                self.log_error(UniqueError("Doublons dans le champs: {}".format(equipement), filename=Equipement.filename, field="EQ_NOM"))
+                
+        zapbos = {}
+        for zapbo in self.dataset[Zapbo]:
+            if not zapbo.ID_ZAPBO in zapbos:
+                zapbos[zapbo.ID_ZAPBO] = zapbo
+            else:
+                self.log_error(UniqueError("Doublons dans le champs: {}".format(zapbo), filename=Zapbo.filename, field="ID_ZAPBO"))
+        
+        # contrôle de la validité des géométries
+        for artere in arteres:
+            if not artere.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(artere), filename=Artere.filename, field="geom"))
+        for tranchee in tranchees:
+            if not tranchee.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(tranchee), filename=Tranchee.filename, field="geom"))
+        for cable in cables:
+            if not "baguette" in cable.CA_COMMENT.lower() and not cable.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(cable), filename=Cable.filename, field="geom"))
+        for noeud in noeuds.values():
+            if not noeud.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(noeud), filename=Noeud.filename, field="geom"))
+        for equipement in equipements.values():
+            if not equipement.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(equipement), filename=Equipement.filename, field="geom"))
+        for zapbo in zapbos.values():
+            if not zapbo.valid:
+                self.log_error(InvalidGeometry("Géométrie invalide: {}".format(zapbo), filename=Zapbo.filename, field="geom"))
+        
+        # rattachement les noeuds aux artères     
+        for artere in arteres:
+            try:
+                artere.noeud_a = noeuds[artere.AR_NOEUD_A]
+            except KeyError:
+                artere.noeud_a = None
+                self.log_error(RelationError("Le noeud '{}' n'existe pas".format(artere.AR_NOEUD_A), filename=Artere.filename, field="AR_NOEUD_A"))
+                
+            try:
+                artere.noeud_b = noeuds[artere.AR_NOEUD_B]
+            except KeyError:
+                artere.noeud_b = None
+                self.log_error(RelationError("Le noeud '{}' n'existe pas".format(artere.AR_NOEUD_B), filename=Artere.filename, field="AR_NOEUD_A"))
+        
+        # rattachement des equipements aux cables
+        for cable in cables:
+            try:
+                cable.equipement_a = equipements[cable.CA_EQ_A]
+            except KeyError:
+                cable.equipement_a = None
+                self.log_error(RelationError("L'équipement '{}' n'existe pas".format(cable.CA_EQ_A), filename=Cable.filename, field="CA_EQ_A"))
+                
+            try:
+                cable.equipement_b = equipements[cable.CA_EQ_B]
+            except KeyError:
+                cable.equipement_b = None
+                self.log_error(RelationError("L'équipement '{}' n'existe pas".format(cable.CA_EQ_B), filename=Cable.filename, field="CA_EQ_B"))
+
+        # rattachement des equipements aux noeuds
+        for equipement in equipements.values():
+            try:
+                equipement.noeud = noeuds[equipement.EQ_NOM_NOE]
+            except KeyError:
+                equipement.noeud = None
+                self.log_error(RelationError("Le noeud '{}' n'existe pas".format(equipement.EQ_NOM_NOE, equipement.EQ_NOM), filename=Equipement.filename, field="EQ_NOM_NOE"))
+
+        # verifie que tous les equipements sont l'equipement B d'au moins un cable
+        equipements_b = [cable.CA_EQ_B for cable in cables]
+        for eq_id in equipements:
+            if equipements[eq_id].EQ_TYPE == "BAI":
+                continue
+            if not eq_id in equipements_b:
+                self.log_error(RelationError("L'equipement '{}' n'est l'équipement B d'aucun cable".format(eq_id), filename=Equipement.filename, field="EQ_NOM"))
+
+        # controle des doublons graphiques
+        for i, tranchee in enumerate(tranchees):
+            for other in tranchees[i+1:]:
+                if tranchee.geom == other.geom:
+                    self.log_error(DuplicatedGeom("Une entité graphique est dupliquée".format(tranchee), filename=Tranchee.filename, field="geom"))
+                    
+        for i, artere in enumerate(arteres):
+            for other in arteres[i+1:]:
+                if artere.geom == other.geom:
+                    self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(artere), filename=Artere.filename, field="geom"))
+
+        for i, cable in enumerate(cables):
+            for other in cables[i+1:]:
+                if cable.geom == other.geom and cable.CA_EQ_A == other.CA_EQ_A and cable.CA_EQ_B == other.CA_EQ_B:
+                    self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(cable), filename=Cable.filename, field="geom"))
+        
+        ls_noeuds = list(noeuds.values())
+        for i, noeud in enumerate(ls_noeuds):
+            for other in ls_noeuds[i+1:]:
+                if noeud.geom == other.geom:
+                    self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(noeud), filename=Noeud.filename, field="geom"))
+        del ls_noeuds
+        
+        ls_zapbos = list(zapbos.values())
+        for i, zapbo in enumerate(ls_zapbos):
+            for other in ls_zapbos[i+1:]:
+                if zapbo.geom == other.geom:
+                    self.log_error(DuplicatedGeom("Une entité graphique est dupliquée ('{}')".format(zapbo), filename=Zapbo.filename, field="geom"))
+        del ls_zapbos
+           
+        # Arteres: comparer la géométrie à celle des noeuds
+        for artere in arteres:
+            if not artere.noeud_a or not artere.noeud_b:
+                continue
+            
+            buffer_a, buffer_b = artere.points[0].Buffer(TOLERANCE), artere.points[-1].Buffer(TOLERANCE)
+            
+            if not (buffer_a.Contains(artere.noeud_a.points[0]) and buffer_b.Contains(artere.noeud_b.points[0])) \
+               and not (buffer_a.Contains(artere.noeud_b.points[0]) and buffer_b.Contains(artere.noeud_a.points[0])):
+
+                self.log_error(MissingItem("Pas de noeud aux coordonnées attendues ('{}')".format(artere), filename=Artere.filename, field="geom"))
+        
+        
+        # Cables: comparer la géométrie à celle des equipements (on utilise en fait la geom du noeud correspondant à l'équipement)
+        for cable in cables:
+            if not cable.equipement_a or not cable.equipement_b or not cable.valid or not cable.equipement_a.noeud or not cable.equipement_b.noeud:
+                continue
+            
+            buffer_a, buffer_b = cable.points[0].Buffer(TOLERANCE), cable.points[-1].Buffer(TOLERANCE)
+            
+            if not (buffer_a.Contains(cable.equipement_a.noeud.points[0]) and buffer_b.Contains(cable.equipement_b.noeud.points[0])) \
+               and not (buffer_a.Contains(cable.equipement_b.noeud.points[0]) and buffer_b.Contains(cable.equipement_a.noeud.points[0])):
+            
+                self.log_error(MissingItem("Pas d'equipement aux coordonnées attendues ('{}')".format(cable), filename=Cable.filename, field="geom"))
+            
+        del buffer_a, buffer_b 
+        
+        # Verifie que chaque tranchée a au moins une artère
+        arteres_emprise = Feature.buffered_union(arteres, TOLERANCE)
+        
+        for tranchee in tranchees:
+            if not arteres_emprise.Contains(tranchee.geom):
+                self.log_error(MissingItem("Tranchée sans artère ('{}')".format(tranchee), filename=Tranchee.filename, field="-"))
+        
+        
+        # Verifie que chaque cable a au moins une artère (sauf si commentaire contient 'baguette')
+        for cable in cables:
+            if "baguette" in cable.CA_COMMENT.lower() or not cable.valid:
+                continue
+            if not arteres_emprise.Contains(cable.geom):
+                self.log_error(MissingItem("Cable sans artère ('{}')".format(cable), filename=Cable.filename, field="-"))
+        
+        del arteres_emprise
+        
+        # Verifie que chaque artère a au moins un cable (sauf si commentaire contient un de ces mots 'racco client adductio attente bus 'sans cable'')
+        cables_emprise = Feature.buffered_union(cables, TOLERANCE)
+        
+        for artere in arteres:
+            if any(x in artere.AR_COMMENT.lower() for x in ['racco','client','adductio','attente','bus','sans cable']):
+                continue
+            if not cables_emprise.Contains(artere.geom):
+                self.log_error(MissingItem("Artère sans cable ('{}')".format(artere), filename=Artere.filename, field="-"))
+                
+        del cables_emprise
+                
+        # Contrôle des dimensions logiques
+        for artere in arteres:
+            try:
+                if not int(artere.AR_FOU_DIS) <= int(artere.AR_NB_FOUR):
+                    self.log_error(DimensionError("Le nombre de fourreaux disponibles doit être inférieur au nombre total ('{}')".format(artere), filename=Artere.filename, field="AR_FOU_DIS"))
+            except (TypeError, ValueError):
+                pass
+        
+        for cable in cables:
+            try:
+                if not int(cable.CA_NB_FO_U) <= int(cable.CA_NB_FO):
+                    self.log_error(DimensionError("Le nombre de fourreaux utilisés doit être inférieur au nombre total ('{}')".format(cable), filename=Cable.filename, field="CA_NB_FO_U"))
+                if not int(cable.CA_NB_FO_D) <= int(cable.CA_NB_FO):
+                    self.log_error(DimensionError("Le nombre de fourreaux disponibles doit être inférieur au nombre total ('{}')".format(cable), filename=Cable.filename, field="CA_NB_FO_D"))
+            except (TypeError, ValueError):
+                pass
+        
+        ant_db = mn.ANTDb_0()
+        ant_db.execute("alter session set NLS_NUMERIC_CHARACTERS = '.,';") # definit le separateur decimal sur '.'
+        
+        # Toutes les zapbo contiennent au moins une prise
+        for zapbo in zapbos.values():
+            
+            if len(zapbo.points) >= 499:
+                # passe l'erreur provoquée par la limite au nombre d'arguments en SQL
+                zapbo.nb_prises = None
+                continue
+            
+            sql = """Select SUM(NB_PRISE) AS NB_PRISES FROM SIG_ANT.FTTH_MN_PRISE_LOT z 
+                       WHERE SDO_INSIDE(z.GEOMETRY,
+                                      SDO_GEOMETRY(2003, 3949, SDO_POINT_TYPE(null,null,null), SDO_ELEM_INFO_ARRAY(1,1003,1),  SDO_ORDINATE_ARRAY({}))
+                                         )='TRUE';""".format(", ".join(["{},{}".format(p.GetX(), p.GetY()) for p in zapbo.points]))
+            
+            zapbo.nb_prises = int(ant_db.first(sql).NB_PRISES)
+            if not zapbo.nb_prises:
+                self.log_error(MissingItem("La Zapbo ne contient aucune prise: {}".format(zapbo), filename=Zapbo.filename, field="-"))
+        
+        # Toutes les prises de la ou les ZAPM impactées sont dans une zapbo
+        zapms = {}
+        # > on déduit la liste des zapms à partir de la position des zapbos
+        for zapbo in zapbos.values():
+            centre = zapbo.geom.Centroid()
+            zapm = ant_db.first("""SELECT z.ID_ZAPM 
+                                  FROM SIG_ANT.FTTH_MN_ZAPM z
+                                  WHERE sdo_contains(z.GEOMETRY, 
+                                                     SDO_GEOMETRY(2001, 3949, SDO_POINT_TYPE({}, {}, NULL), NULL, NULL)) = 'TRUE'
+                               """.format(centre.GetX(), centre.GetY()))
+            try:
+                zapms[zapm.ID_ZAPM].append(zapbo)
+            except KeyError:
+                zapms[zapm.ID_ZAPM] = [zapbo]
+        
+        for id_zapm in zapms:
+            zapm_couverture = Feature.union(zapms[id_zapm])
+            for prise in ant_db.read("""SELECT t.X AS x, t.Y AS y
+                                        FROM SIG_ANT.FTTH_MN_PRISE_LOT z, 
+                                        TABLE(SDO_UTIL.GETVERTICES(z.GEOMETRY)) t 
+                                        WHERE T_ETAT<>'OBSOLETE' AND ID_ZAPM_PARTIELLE='{}';""".format(id_zapm)):
+                point = ogr.Geometry(ogr.wkbPoint)
+                point.AddPoint(prise.x, prise.y)
+                if not zapm_couverture.Contains(point):
+                    self.log_error(MissingItem("Certaines prises de la ZAPM ne sont pas comprises dans une ZAPBO: {}".format(id_zapm), filename=Zapbo.filename, field="-"))
+        
+        # Verifier que chaque equipement de type PBO est contenu dans une zapbo, et que le nom de la zapbo contient le nom de l'equipement
+        
+        for equipement in equipements.values():
+            if not equipement.EQ_TYPE == "PBO":
+                continue
+            
+            #zapbos englobant l'equipement
+            candidates = []
+            for zapbo in zapbos.values():
+                if zapbo.geom.Contains(equipement.geom):
+                    candidates.append(zapbo)
+                    
+            # le pbo doit être contenu dans une zapbo
+            if not candidates:
+                self.log_error(MissingItem("Le PBO n'est contenu dans aucune ZAPBO: {}".format(equipement), filename=Equipement.filename, field="geom"))
+                continue
+            
+            # On se base sur le nom pour trouver la zapbo correspondante
+            try:
+                equipement.zapbo = next((z for z in candidates if equipement.EQ_NOM in z.ID_ZAPBO))
+            except StopIteration:
+                self.log_error(MissingItem("Le nom du PBO ne coincide avec le nom d'aucune des ZAPBO qui le contient: {}".format(equipement), filename=Equipement.filename, field="EQ_NOM"))
+                break
+            
+            if equipement.zapbo.nb_prises is None:
+                equipement.zapbo.nb_prises = 0
+            
+            # Controle du dimensionnement des PBO
+            if equipement.zapbo.nb_prises is not None:
+                if equipement.EQ_TYPE_PH == 'PBO 6' and not equipement.zapbo.nb_prises < 6:
+                    self.log_error(DimensionError("Le PBO 6 contient plus de 5 prises: {}".format(equipement), filename=Equipement.filename, field="-"))
+            
+                if equipement.EQ_TYPE_PH == 'PBO 12' and not equipement.zapbo.nb_prises >= 6 and equipement.zapbo.nb_prises <= 8:
+                    self.log_error(DimensionError("Le PBO 12 contient mois de 6 prises ou plus de 8 prises: {}".format(equipement), filename=Equipement.filename, field="-"))
+        
+            if equipement.zapbo.STATUT == "REC" and not equipement.EQ_STATUT == "REC":
+                self.log_error(TechnicalValidationError("Le statut du PBO n'est pas cohérent avec le statut de sa ZAPBO: {}".format(equipement), filename=Equipement.filename, field="-"))
+        
+            if equipement.EQ_STATUT == "REC" and not equipement.zapbo.STATUT == "REC" and not equipement.zapbo.ID_ZAPBO[:4].lower() == "att_":
+                self.log_error(TechnicalValidationError("Le statut du PBO n'est pas cohérent avec le statut de sa ZAPBO: {}".format(equipement), filename=Equipement.filename, field="-"))
+        
+        # Contrôler dans la base si des éléments portant ces codes existent à des emplacements différents
+        for noeud in noeuds.values():
+            sql = """SELECT z.NO_NOM, SDO_GEOM.SDO_DISTANCE(z.GEOMETRY, SDO_GEOMETRY(2001, 3949, SDO_POINT_TYPE({}, {}, NULL), NULL, NULL),0.005) AS DIST 
+                     FROM SIG_ANT.FTTH_MN_GR_NOEUD_GEO z 
+                     WHERE z.NO_NOM='{}';""".format(noeud.geom.GetX(), noeud.geom.GetY(), noeud.NO_NOM)
+            existing = ant_db.first(sql)
+            if existing:
+                if existing.DIST > TOLERANCE and existing.DIST < 20:
+                    self.log_error(PositionError("La position du noeud ne correspond pas à l'existant: {}".format(noeud), filename=Noeud.filename, field="geom"))
+                elif existing.DIST > 20:
+                    self.log_error(DuplicatedGeom("Un noeud portant ce nom existe déjà ailleurs sur le territoire: {}".format(noeud), filename=Noeud.filename, field="NO_NOM"))
+        
+        for zapbo in zapbos.values():
+            sql = """SELECT z.ID_ZAPBO, SDO_GEOM.SDO_DISTANCE(SDO_GEOM.SDO_CENTROID(z.GEOMETRY,0.005), SDO_GEOMETRY(2001, 3949, SDO_POINT_TYPE({}, {}, NULL), NULL, NULL),0.005) AS DIST 
+                     FROM SIG_ANT.FTTH_MN_GR_ZAPBO_GEO z 
+                     WHERE z.ID_ZAPBO='{}';""".format(zapbo.geom.Centroid().GetX(), zapbo.geom.Centroid().GetY(), zapbo.ID_ZAPBO)
+            existing = ant_db.first(sql)
+            if existing:
+                if existing.DIST > TOLERANCE and existing.DIST < 20:
+                    self.log_error(PositionError("La position de la ZAPBO ne correspond pas à l'existant: {}".format(zapbo), filename=Zapbo.filename, field="geom"))
+                elif existing.DIST > 20:
+                    self.log_error(DuplicatedGeom("Une ZAPBO portant ce nom existe déjà ailleurs sur le territoire: {}".format(zapbo), filename=Zapbo.filename, field="ID_ZAPBO"))
+
+        # Contrôle si un equipement portant ce nom existe, mais associé à un noeud différent
+        for equipement in equipements.values():
+            sql = """SELECT z.EQ_NOM, z.EQ_NOM_NOEUD
+                     FROM SIG_ANT.FTTH_MN_GR_EQ_PASSIF z 
+                     WHERE z.EQ_NOM='{}';""".format(equipement.EQ_NOM)
+            existing = ant_db.first(sql)
+            if existing and existing.EQ_NOM_NOEUD != equipement.EQ_NOM_NOE:
+                self.log_error(DuplicatedGeom("Un équipement portant ce nom ({}) existe déjà et est associé à un noeud différent ({})".format(equipement.NO_NOM, existing.EQ_NOM_NOEUD), filename=Noeud.filename, field="geom"))
+        
+        
+        
+    
+if __name__ == "__main__":
+    from core.constants import MAIN
+    subject = MAIN / "work" / "STURNO_228CP0_APD_180301_OK"
+    report = Netgeo22DoeValidator.submit(subject)
+    print(report)
+    

+ 0 - 0
ui/__init__.py


+ 43 - 0
ui/dlg_main.py

@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+"""
+/***************************************************************************
+ MnCheckDialog
+                                 A QGIS plugin
+ Contrôle des données FTTH format MN
+ Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
+                             -------------------
+        begin                : 2018-12-07
+        git sha              : $Format:%H$
+        copyright            : (C) 2018 by Manche Numérique 2019
+        email                : olivier.massot@manchenumerique.fr
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+import os
+
+from PyQt5 import uic
+from PyQt5 import QtWidgets
+
+FORM_CLASS, _ = uic.loadUiType(os.path.join(
+    os.path.dirname(__file__), 'mncheck_dialog_base.ui'))
+
+
+class DlgMain(QtWidgets.QDialog, FORM_CLASS):
+    def __init__(self, parent=None):
+        """Constructor."""
+        super(DlgMain, self).__init__(parent)
+        # Set up the user interface from Designer.
+        # After setupUI you can access any designer object by doing
+        # self.<objectname>, and you can use autoconnect slots - see
+        # http://qt-project.org/doc/qt-4.8/designer-using-a-ui-file.html
+        # #widgets-and-dialogs-with-auto-connect
+        self.setupUi(self)