class Municipality(NamedModel): identifiers = ['siren', 'insee'] resource_fields = ['name', 'alias', 'insee', 'siren', 'postcodes'] exclude_for_version = ['postcodes'] insee = db.CharField(length=5, unique=True) siren = db.CharField(max_length=9, unique=True, null=True)
class Anomaly(resource.ResourceModel): __openapi__ = """ properties: identifier: type: string description: key/value pair for identifier. . key = identifier name. e.g., 'id'. . value = identifier value. . key and value are separated by a ':' """ resource_fields = ['versions', 'kind', 'insee', 'created_at'] readonly_fields = (resource.ResourceModel.readonly_fields + ['created_at']) versions = db.ManyToManyField(Version, related_name='_anomalies') kind = db.CharField() insee = db.CharField(length=5) created_at = db.DateTimeField() legitimate = db.BooleanField(default=False) def save(self, *args, **kwargs): if not self.created_at: self.created_at = utcnow() return super().save(*args, **kwargs) def mark_deleted(self): self.delete_instance()
class Client(ResourceModel): identifiers = ['client_id'] GRANT_AUTHORIZATION_CODE = 'authorization_code' GRANT_IMPLICIT = 'implicit' GRANT_PASSWORD = '******' GRANT_CLIENT_CREDENTIALS = 'client_credentials' GRANT_TYPES = ( # (GRANT_AUTHORIZATION_CODE, _('Authorization code')), # (GRANT_IMPLICIT, _('Implicit')), (GRANT_PASSWORD, _('Resource owner password-based')), (GRANT_CLIENT_CREDENTIALS, _('Client credentials')), ) default_scopes = ['contrib'] client_id = db.UUIDField(unique=True, default=uuid.uuid4) name = db.CharField(max_length=100) user = db.ForeignKeyField(User) client_secret = db.CharField(unique=True, max_length=55, index=True) redirect_uris = db.ArrayField(db.CharField) grant_type = db.CharField(choices=GRANT_TYPES) is_confidential = db.BooleanField(default=False) @property def default_redirect_uri(self): return self.redirect_uris[0] if self.redirect_uris else None @property def allowed_grant_types(self): return [id for id, name in self.GRANT_TYPES]
class HouseNumber(Model): identifiers = ['cia', 'laposte', 'ign'] resource_fields = [ 'number', 'ordinal', 'parent', 'cia', 'laposte', 'ancestors', 'positions', 'ign', 'postcode' ] readonly_fields = Model.readonly_fields + ['cia'] number = db.CharField(max_length=16, null=True) ordinal = db.CharField(max_length=16, null=True) parent = db.ForeignKeyField(Group) cia = db.CharField(max_length=100, null=True, unique=True) laposte = db.CharField(max_length=10, null=True, unique=True) ign = db.CharField(max_length=24, null=True, unique=True) ancestors = db.ManyToManyField(Group, related_name='_housenumbers') postcode = db.ForeignKeyField(PostCode, null=True) class Meta: indexes = ((('parent', 'number', 'ordinal'), True), ) def __str__(self): return ' '.join([self.number or '', self.ordinal or '']) def save(self, *args, **kwargs): self.cia = self.compute_cia() super().save(*args, **kwargs) self._clean_called = False def compute_cia(self): return compute_cia(str(self.parent.municipality.insee), self.parent.get_fantoir(), self.number, self.ordinal)
class Session(db.Model): """Stores the minimum data to trace the changes. We have two scenarios: - one registered user (a developer?) create its own token, and then gets a nominative session - a client sends us IP and/or email from a remote user we don't know of """ user = db.ForeignKeyField(User, null=True) client = db.ForeignKeyField(Client, null=True) ip = db.CharField(null=True) # TODO IPField email = db.CharField(null=True) # TODO EmailField
class Municipality(NamedModel): identifiers = ['siren', 'insee'] resource_fields = ['name', 'alias', 'insee', 'siren', 'postcodes'] insee = db.CharField(max_length=5, unique=True) siren = db.CharField(max_length=9, unique=True) @property def postcodes_resource(self): return [p.code for p in self.postcodes]
class Grant(db.Model): user = db.ForeignKeyField(User) client = db.ForeignKeyField(Client) code = db.CharField(max_length=255, index=True, null=False) redirect_uri = db.CharField() scope = db.CharField(null=True) expires = db.DateTimeField() @property def scopes(self): return self.scope.split() if self.scope else None
class Client(ResourceModel): identifiers = ['client_id'] resource_fields = ['name', 'user', 'scopes', 'contributor_types'] GRANT_CLIENT_CREDENTIALS = 'client_credentials' GRANT_TYPES = ((GRANT_CLIENT_CREDENTIALS, _('Client credentials')), ) TYPE_IGN = 'ign' TYPE_LAPOSTE = 'laposte' TYPE_DGFIP = 'dgfip' TYPE_ETALAB = 'etalab' TYPE_OSM = 'osm' TYPE_SDIS = 'sdis' TYPE_MUNICIPAL = 'municipal_administration' TYPE_ADMIN = 'admin' TYPE_DEV = 'develop' TYPE_INSEE = 'insee' TYPE_VIEWER = 'viewer' CONTRIBUTOR_TYPE = (TYPE_SDIS, TYPE_OSM, TYPE_LAPOSTE, TYPE_IGN, TYPE_DGFIP, TYPE_ETALAB, TYPE_MUNICIPAL, TYPE_ADMIN, TYPE_INSEE, TYPE_DEV, TYPE_VIEWER) client_id = db.UUIDField(unique=True, default=uuid.uuid4) name = db.CharField(max_length=100) user = db.ForeignKeyField(User) client_secret = db.CharField(unique=True, max_length=55) redirect_uris = db.ArrayField(db.CharField) grant_type = db.CharField(choices=GRANT_TYPES) is_confidential = db.BooleanField(default=False) contributor_types = db.ArrayField(db.CharField, default=[TYPE_VIEWER], null=True) scopes = db.ArrayField(db.CharField, default=[], null=True) @property def default_redirect_uri(self): return self.redirect_uris[0] if self.redirect_uris else None @property def allowed_grant_types(self): return [id for id, name in self.GRANT_TYPES] #Necessaire pour OAuth @property def default_scopes(self): return self.scopes def save(self, *args, **kwargs): if not self.client_secret: self.client_secret = generate_secret() self.redirect_uris = ['http://localhost/authorize'] # FIXME self.grant_type = self.GRANT_CLIENT_CREDENTIALS if not self.contributor_types: self.contributor_types = ['viewer'] super().save(*args, **kwargs)
class Municipality(NamedModel): INSEE_FORMAT = '(2[AB]|\d{2})\d{3}' identifiers = ['siren', 'insee'] resource_fields = ['name', 'alias', 'insee', 'siren', 'postcodes'] exclude_for_version = ['postcodes'] insee = db.CharField(length=5, unique=True, format=INSEE_FORMAT) siren = db.CharField(length=9, format='\d*', unique=True, null=True) @property def municipality(self): return self
class HouseNumber(Model): # INSEE + set of OCR-friendly characters (dropped confusing ones # (like 0/O, 1/I…)) from La Poste. CEA_FORMAT = Municipality.INSEE_FORMAT + '[234679ABCEGHILMNPRSTUVXYZ]{5}' identifiers = ['cia', 'laposte', 'ign'] resource_fields = [ 'number', 'ordinal', 'parent', 'cia', 'laposte', 'ancestors', 'positions', 'ign', 'postcode' ] exclude_for_collection = ['ancestors'] number = db.CharField(max_length=16, null=True) ordinal = db.CharField(max_length=16, null=True) parent = db.ForeignKeyField(Group) cia = db.CharField(max_length=100, null=True, unique=True) laposte = db.CharField(length=10, null=True, unique=True, format=CEA_FORMAT) ign = db.CharField(max_length=24, null=True, unique=True) ancestors = db.ManyToManyField(Group, related_name='_housenumbers') postcode = db.CachedForeignKeyField(PostCode, null=True) class Meta: indexes = ((('parent', 'number', 'ordinal'), True), ) case_ignoring = ('ordinal', ) def __str__(self): return ' '.join([self.number or '', self.ordinal or '']) def save(self, *args, **kwargs): self.cia = self.compute_cia() super().save(*args, **kwargs) self._clean_called = False def compute_cia(self): return compute_cia(self.parent.fantoir[:5], self.parent.fantoir[5:], self.number, self.ordinal) if self.parent.fantoir else None @cached_property def municipality(self): return Municipality.select().where( Municipality.pk == self.parent.municipality.pk).first() @property def as_export(self): """Resources plus relation references without metadata.""" mask = {f: {} for f in self.resource_fields} return self.serialize(mask)
class PostCode(NamedModel): resource_fields = ['code', 'name', 'alias', 'complement', 'municipality'] complement = db.CharField(max_length=38, null=True) code = db.CharField(index=True, format='\d*', length=5) municipality = db.ForeignKeyField(Municipality, related_name='postcodes') class Meta: indexes = ((('code', 'complement', 'municipality'), True), ) @property def housenumbers(self): return self.housenumber_set.order_by( peewee.SQL('number ASC NULLS FIRST'), peewee.SQL('ordinal ASC NULLS FIRST'))
class Version(db.Model): model_name = db.CharField(max_length=64) model_id = db.IntegerField() sequential = db.IntegerField() data = db.BinaryJSONField() class Meta: manager = SelectQuery def __repr__(self): return '<Version {} of {}({})>'.format(self.sequential, self.model_name, self.model_id) @property def as_resource(self): return json.loads(self.data) @property def model(self): return BaseVersioned.registry[self.model_name] def load(self): return self.model(**self.as_resource) @property def diff(self): return Diff.first(Diff.new == self.id)
class User(ResourceModel): identifiers = ['email'] resource_fields = ['username', 'email', 'company'] username = db.CharField(max_length=100, index=True) email = db.CharField(max_length=100, unique=True) company = db.CharField(max_length=100, null=True) is_staff = db.BooleanField(default=False, index=True) auth = 1 class Meta: database = db.database def __str__(self): return self.username
class Session(db.Model): """Stores the minimum data to trace the changes. We have two scenarios: - one registered user (a developer?) create its own token, and then gets a nominative session - a client sends us IP and/or email from a remote user we don't know of """ __openapi__ = """ properties: id: type: integer description: primary key of the session client: type: string description: client name user: type: string description: user name """ user = db.CachedForeignKeyField(User, null=True) client = db.CachedForeignKeyField(Client, null=True) ip = db.CharField(null=True) # TODO IPField email = db.CharField(null=True) # TODO EmailField contributor_type = db.CharField(null=True) def serialize(self, *args): # Pretend to be a resource for created_by/modified_by values in # resources serialization. # Should we also expose the email/ip? CNIL question to be solved. return { 'id': self.pk, 'client': self.client.name if self.client else None, 'user': self.user.username if self.user else None, 'contributor_type': self.contributor_type if self.contributor_type else None } def save(self, **kwargs): if not self.user and not self.client: raise ValueError('Session must have either a client or a user') if not self.contributor_type: raise ValueError('Session must have a contributor type') super().save(**kwargs)
class Version(db.Model): __openapi__ = """ properties: data: type: object description: serialized resource flag: type: array items: $ref: '#/definitions/Flag' """ model_name = db.CharField(max_length=64) model_pk = db.IntegerField() sequential = db.IntegerField() data = db.BinaryJSONField() period = db.DateRangeField() class Meta: indexes = ((('model_name', 'model_pk', 'sequential'), True), ) def __repr__(self): return '<Version {} of {}({})>'.format(self.sequential, self.model_name, self.model_pk) def serialize(self, *args): return {'data': self.data, 'flags': list(self.flags.serialize())} @property def model(self): return BaseVersioned.registry[self.model_name] def load(self): validator = self.model.validator(**self.data) return self.model(**validator.data) @property def diff(self): return Diff.first(Diff.new == self.pk) @flag_id_required def flag(self, session=None): """Flag current version with current client.""" if not Flag.where(Flag.version == self, Flag.client == session.client).exists(): Flag.create(version=self, session=session, client=session.client) @flag_id_required def unflag(self, session=None): """Delete current version's flags made by current session client.""" Flag.delete().where(Flag.version == self, Flag.client == session.client).execute() def close_period(self, bound): # DateTimeRange is immutable, so create new one. self.period = [self.period.lower, bound] self.save()
class Group(NamedModel): AREA = 'area' WAY = 'way' KIND = ( (WAY, 'way'), (AREA, 'area'), ) CLASSICAL = 'classical' METRIC = 'metric' LINEAR = 'linear' MIXED = 'mixed' ANARCHICAL = 'anarchical' ADDRESSING = ( (CLASSICAL, 'classical'), (METRIC, 'metric'), (LINEAR, 'linear'), (MIXED, 'mixed types'), (ANARCHICAL, 'anarchical'), ) identifiers = ['fantoir', 'laposte', 'ign'] resource_fields = [ 'name', 'alias', 'fantoir', 'municipality', 'kind', 'laposte', 'ign', 'addressing' ] kind = db.CharField(max_length=64, choices=KIND) addressing = db.CharField(max_length=16, choices=ADDRESSING, null=True) fantoir = db.FantoirField(null=True, unique=True) laposte = db.CharField(max_length=10, null=True, unique=True) ign = db.CharField(max_length=24, null=True, unique=True) municipality = db.ForeignKeyField(Municipality, related_name='groups') @property def tmp_fantoir(self): return '#' + re.sub(r'[\W]', '', unidecode(self.name)).upper() def get_fantoir(self): # Without INSEE code. return self.fantoir[5:] if self.fantoir else self.tmp_fantoir @property def housenumbers(self): qs = (self._housenumbers | self.housenumber_set) return qs.order_by(peewee.SQL('number ASC NULLS FIRST'), peewee.SQL('ordinal ASC NULLS FIRST'))
class NamedModel(Model): name = db.CharField(max_length=200) alias = db.ArrayField(db.CharField, null=True) def __str__(self): return self.name class Meta: abstract = True ordering = ('name', )
class HouseNumber(Model): # INSEE + set of OCR-friendly characters (dropped confusing ones # (like 0/O, 1/I…)) from La Poste. CEA_FORMAT = Municipality.INSEE_FORMAT + '[234679ABCEGHILMNPRSTUVXYZ]{5}' identifiers = ['cia', 'laposte', 'ign'] resource_fields = [ 'number', 'ordinal', 'parent', 'cia', 'laposte', 'ancestors', 'positions', 'ign', 'postcode' ] readonly_fields = Model.readonly_fields + ['cia'] number = db.CharField(max_length=16, null=True) ordinal = db.CharField(max_length=16, null=True) parent = db.ForeignKeyField(Group) cia = db.CharField(max_length=100, null=True, unique=True) laposte = db.CharField(length=10, null=True, unique=True, format=CEA_FORMAT) ign = db.CharField(max_length=24, null=True, unique=True) ancestors = db.ManyToManyField(Group, related_name='_housenumbers') postcode = db.ForeignKeyField(PostCode, null=True) class Meta: indexes = ((('parent', 'number', 'ordinal'), True), ) def __str__(self): return ' '.join([self.number or '', self.ordinal or '']) def save(self, *args, **kwargs): self.cia = self.compute_cia() super().save(*args, **kwargs) self._clean_called = False def compute_cia(self): return compute_cia(str(self.parent.municipality.insee), self.parent.get_fantoir(), self.number, self.ordinal) @cached_property def municipality(self): return Municipality.select().join( Group, on=Municipality.pk == self.parent.municipality.pk).first()
class Proxy(db.Model): kind = db.CharField(max_length=50, null=False) @property def real(self): # Make dynamic. mapping = {class_.__name__.lower(): class_ for class_ in [Street, Locality, PostCode, District]} class_ = mapping[self.kind] return class_.get(class_.proxy == self)
class Client(ResourceModel): identifiers = ['client_id'] resource_fields = ['name', 'user'] GRANT_AUTHORIZATION_CODE = 'authorization_code' GRANT_IMPLICIT = 'implicit' GRANT_PASSWORD = '******' GRANT_CLIENT_CREDENTIALS = 'client_credentials' GRANT_TYPES = ( # (GRANT_AUTHORIZATION_CODE, _('Authorization code')), # (GRANT_IMPLICIT, _('Implicit')), (GRANT_PASSWORD, _('Resource owner password-based')), (GRANT_CLIENT_CREDENTIALS, _('Client credentials')), ) default_scopes = ['contrib'] FLAGS = ['ign', 'laposte', 'local_authority'] FLAG_IDS = tuple((i, i) for i in FLAGS) + (None, 'None') client_id = db.UUIDField(unique=True, default=uuid.uuid4) name = db.CharField(max_length=100) user = db.ForeignKeyField(User) client_secret = db.CharField(unique=True, max_length=55) redirect_uris = db.ArrayField(db.CharField) grant_type = db.CharField(choices=GRANT_TYPES) is_confidential = db.BooleanField(default=False) flag_id = db.CharField(choices=FLAG_IDS, default=None, null=True) @property def default_redirect_uri(self): return self.redirect_uris[0] if self.redirect_uris else None @property def allowed_grant_types(self): return [id for id, name in self.GRANT_TYPES] def save(self, *args, **kwargs): if not self.client_secret: self.client_secret = generate_secret() self.redirect_uris = ['http://localhost/authorize'] # FIXME self.grant_type = self.GRANT_CLIENT_CREDENTIALS super().save(*args, **kwargs)
class Position(Model): POSTAL = 'postal' ENTRANCE = 'entrance' BUILDING = 'building' STAIRCASE = 'staircase' UNIT = 'unit' PARCEL = 'parcel' SEGMENT = 'segment' UTILITY = 'utility' KIND = ( (POSTAL, _('postal delivery')), (ENTRANCE, _('entrance')), (BUILDING, _('building')), (STAIRCASE, _('staircase identifier')), (UNIT, _('unit identifier')), (PARCEL, _('parcel')), (SEGMENT, _('road segment')), (UTILITY, _('utility service')), ) resource_fields = ['center', 'source', 'housenumber', 'attributes', 'kind', 'comment', 'parent'] center = db.PointField(verbose_name=_("center")) housenumber = db.ForeignKeyField(HouseNumber) parent = db.ForeignKeyField('self', related_name='children', null=True) source = db.CharField(max_length=64, null=True) kind = db.CharField(max_length=64, choices=KIND) attributes = db.HStoreField(null=True) comment = peewee.TextField(null=True) class Meta: unique_together = ('housenumber', 'source') @property def center_resource(self): if not isinstance(self.center, Point): self.center = Point(*self.center) return self.center.geojson
class BaseFantoirModel(ProxyableModel, NamedModel): identifiers = ['fantoir'] resource_fields = ['name', 'alias', 'fantoir', 'municipality'] fantoir = db.CharField(max_length=9, null=True, index=True) class Meta: abstract = True @property def tmp_fantoir(self): return '#' + re.sub(r'[\W]', '', unidecode(self.name)).upper() def get_fantoir(self): return self.fantoir or self.tmp_fantoir
class User(ResourceModel): identifiers = ['email'] resource_fields = ['username', 'email', 'company'] username = db.CharField(max_length=100) email = db.CharField(max_length=100, unique=True) company = db.CharField(max_length=100, null=True) # Allow null, because password is not a resource field, and thus cannot be # passed to validators. password = db.PasswordField(null=True) is_staff = db.BooleanField(default=False) class Meta: database = db.default def __str__(self): return self.username def set_password(self, password): self.password = password self.save() def check_password(self, password): return self.password.check_password(password)
class Token(db.Model): session = db.ForeignKeyField(Session) token_type = db.CharField(max_length=40) access_token = db.CharField(max_length=255) refresh_token = db.CharField(max_length=255, null=True) scope = db.CharField(max_length=255) expires = db.DateTimeField() def __init__(self, **kwargs): expires_in = kwargs.pop('expires_in', 60 * 60) kwargs['expires'] = datetime.now() + timedelta(seconds=expires_in) super().__init__(**kwargs) @property def scopes(self): # TODO: custom charfield return self.scope.split() if self.scope else None def is_valid(self, scopes=None): """ Checks if the access token is valid. :param scopes: An iterable containing the scopes to check or None """ return not self.is_expired() and self.allow_scopes(scopes) def is_expired(self): """ Check token expiration with timezone awareness """ return datetime.now() >= self.expires def allow_scopes(self, scopes): """ Check if the token allows the provided scopes :param scopes: An iterable containing the scopes to check """ if not scopes: return True provided_scopes = set(self.scope.split()) resource_scopes = set(scopes) return resource_scopes.issubset(provided_scopes) @property def user(self): return self.session.user @classmethod def create_with_session(cls, **data): if not data.get('ip') and not data.get('email'): return None if not data.get('client_id'): return None session_data = { "email": data.get('email'), "ip": data.get('ip'), "client": Client.first(Client.client_id == data['client_id']) } session = Session.create(**session_data) # get or create? data['session'] = session.id return Token.create(**data)
class Token(db.Model): session = db.ForeignKeyField(Session) token_type = db.CharField(max_length=40) access_token = db.CharField(max_length=255) refresh_token = db.CharField(max_length=255, null=True) scopes = db.ArrayField(db.CharField, default=[], null=True) expires = db.DateTimeField() contributor_type = db.CharField(choices=Client.CONTRIBUTOR_TYPE, null=True) def __init__(self, **kwargs): expires_in = kwargs.pop('expires_in', 60 * 60) kwargs['expires'] = utcnow() + timedelta(seconds=expires_in) super().__init__(**kwargs) def is_valid(self, scopes=None): """ Checks if the access token is valid. :param scopes: An iterable containing the scopes to check or None """ return not self.is_expired() and self.allow_scopes(scopes) def is_expired(self): """ Check token expiration with timezone awareness """ return utcnow() >= self.expires def allow_scopes(self, scopes): """ Check if the token allows the provided scopes :param scopes: An iterable containing the scopes to check """ if not scopes: return True provided_scopes = set(self.scope.split()) resource_scopes = set(scopes) return resource_scopes.issubset(provided_scopes) @property def user(self): return self.session.user @classmethod def create_with_session(cls, **data): if not data.get('ip') and not data.get('email'): return None, None if not data.get('client_id'): return None, 'Client id missing' client = Client.first(Client.client_id == data['client_id']) if len(client.contributor_types) == 0: return None, 'Client has none contributor types' contributor_type = client.contributor_types[0] if data.get('contributor_type'): if data.get('contributor_type') not in client.contributor_types: return None, 'wrong contributor type : must be in the list {}'.format( client.contributor_types) if len(client.contributor_types) > 1: if not data.get('contributor_type'): return None, 'Contributor type missing' contributor_type = data.get('contributor_type') session_data = { "email": data.get('email'), "ip": data.get('ip'), "contributor_type": contributor_type, "client": client } session = Session.create(**session_data) # get or create? data['session'] = session.pk data['scopes'] = client.scopes data['contributor_type'] = session.contributor_type if session.contributor_type == Client.TYPE_VIEWER: data['scopes'] = None return Token.create(**data), None
class Diff(db.Model): __openapi__ = """ properties: increment: type: integer description: incremental id of the diff resource: type: string description: name of the resource the diff is applied to resource_id: type: string description: id of the resource the diff is applied to created_at: type: string format: date-time description: the date and time the diff has been created at insee: type: string description: INSEE code of the Municipality the resource is attached old: type: object description: the resource before the change new: type: object description: the resource after the change diff: type: object description: detail of changed properties """ # Allow to skip diff at very first data import. ACTIVE = True # old is empty at creation. old = db.ForeignKeyField(Version, null=True) # new is empty after delete. new = db.ForeignKeyField(Version, null=True) insee = db.CharField(length=5) diff = db.BinaryJSONField() created_at = db.DateTimeField() class Meta: validate_backrefs = False order_by = ('pk', ) def save(self, *args, **kwargs): if not self.diff: old = self.old.data if self.old else {} new = self.new.data if self.new else {} self.diff = make_diff(old, new) super().save(*args, **kwargs) Redirect.from_diff(self) def serialize(self, *args): version = self.new or self.old return { 'increment': self.pk, 'insee': self.insee, 'old': self.old.data if self.old else None, 'new': self.new.data if self.new else None, 'diff': self.diff, 'resource': version.model_name.lower(), 'resource_id': version.data['id'], 'created_at': self.created_at }
class Redirect(db.Model): __openapi__ = """ properties: identifier: type: string description: key/value pair for identifier. . key = identifier name. e.g., 'id'. . value = identifier value. . key and value are separated by a ':' """ model_name = db.CharField(max_length=64) model_id = db.CharField(max_length=255) identifier = db.CharField(max_length=64) value = db.CharField(max_length=255) class Meta: primary_key = peewee.CompositeKey('model_name', 'identifier', 'value', 'model_id') @classmethod def add(cls, instance, identifier, value): if isinstance(instance, tuple): # Optim so we don't need to request db when creating a redirect # from a diff. model_name, model_id = instance else: model_name = instance.resource model_id = instance.id if identifier not in instance.__class__.identifiers + ['id', 'pk']: raise ValueError('Invalid identifier: {}'.format(identifier)) if getattr(instance, identifier) == value: raise ValueError('Redirect cannot point to itself') cls.get_or_create(model_name=model_name, identifier=identifier, value=str(value), model_id=model_id) cls.propagate(model_name, identifier, value, model_id) @classmethod def remove(cls, instance, identifier, value): cls.delete().where(cls.model_name == instance.resource, cls.identifier == identifier, cls.value == str(value), cls.model_id == instance.id).execute() @classmethod def clear(cls, instance): cls.delete().where(cls.model_name == instance.resource, cls.model_id == instance.id).execute() @classmethod def from_diff(cls, diff): if not diff.new or not diff.old: # Only update makes sense for us, not creation nor deletion. return model = diff.new.model identifiers = [i for i in model.identifiers if i in diff.diff] for identifier in identifiers: old = diff.diff[identifier]['old'] new = diff.diff[identifier]['new'] if not old or not new: continue cls.add((model.__name__.lower(), diff.new.data['id']), identifier, old) @classmethod def follow(cls, model_name, identifier, value): rows = cls.select(cls.model_id).where( cls.model_name == model_name.lower(), cls.identifier == identifier, cls.value == str(value)) return [row.model_id for row in rows] @classmethod def propagate(cls, model_name, identifier, value, model_id): """An identifier was a target and it becomes itself a redirect.""" model = BaseVersioned.registry.get(model_name) if model: old = model.first(getattr(model, identifier) == value) if old: cls.update(model_id=model_id).where( cls.model_id == old.id, cls.model_name == model_name).execute() def serialize(self, *args): return '{}:{}'.format(self.identifier, self.value)
class Position(Model): POSTAL = 'postal' ENTRANCE = 'entrance' BUILDING = 'building' STAIRCASE = 'staircase' UNIT = 'unit' PARCEL = 'parcel' SEGMENT = 'segment' UTILITY = 'utility' UNKNOWN = 'unknown' AREA = 'area' KIND = ( (POSTAL, _('postal delivery')), (ENTRANCE, _('entrance')), (BUILDING, _('building')), (STAIRCASE, _('staircase identifier')), (UNIT, _('unit identifier')), (PARCEL, _('parcel')), (SEGMENT, _('road segment')), (UTILITY, _('utility service')), (AREA, _('area')), (UNKNOWN, _('unknown')), ) DGPS = 'dgps' GPS = 'gps' IMAGERY = 'imagery' PROJECTION = 'projection' INTERPOLATION = 'interpolation' OTHER = 'other' POSITIONING = ( (DGPS, _('via differencial GPS')), (GPS, _('via GPS')), (IMAGERY, _('via imagery')), (PROJECTION, _('computed via projection')), (INTERPOLATION, _('computed via interpolation')), (OTHER, _('other')), ) identifiers = ['laposte', 'ign'] resource_fields = [ 'center', 'source', 'housenumber', 'kind', 'comment', 'parent', 'positioning', 'name', 'ign', 'laposte' ] name = db.CharField(max_length=200, null=True) center = db.PointField(verbose_name=_("center"), null=True, index=True) housenumber = db.ForeignKeyField(HouseNumber, related_name='positions') parent = db.ForeignKeyField('self', related_name='children', null=True) source = db.CharField(max_length=64, null=True) kind = db.CharField(max_length=64, choices=KIND) positioning = db.CharField(max_length=32, choices=POSITIONING) ign = db.CharField(max_length=24, null=True, unique=True) laposte = db.CharField(max_length=10, null=True, unique=True) comment = db.TextField(null=True) class Meta: indexes = ((('housenumber', 'source'), True), ) @classmethod def validate(cls, validator, document, instance): errors = {} default = instance and validator.update and instance.name name = document.get('name', default) default = instance and validator.update and instance.center center = document.get('center', default) if not name and not center: msg = 'A position must have either a center or a name.' errors['center'] = msg errors['name'] = msg return errors
class ResourceModel(db.Model, metaclass=BaseResource): resource_fields = ['id', 'status'] identifiers = [] readonly_fields = ['id', 'pk', 'status', 'deleted_at'] exclude_for_collection = ['status'] exclude_for_version = [] id = db.CharField(max_length=50, unique=True, null=False) deleted_at = db.DateTimeField(null=True, index=True) class Meta: validator = ResourceValidator @classmethod def make_id(cls): return 'ban-{}-{}'.format(cls.__name__.lower(), uuid.uuid4().hex) def save(self, *args, **kwargs): if not self.id: self.id = self.make_id() return super().save(*args, **kwargs) @classmethod def validator(cls, instance=None, update=False, **data): validator = cls._meta.validator(cls, update=update) validator.validate(data, instance=instance) return validator @property def resource(self): return self.__class__.__name__.lower() @property def serialized(self): return self.id def serialize(self, mask=None): if not mask: return self.serialized dest = {} for name, subfields in mask.items(): if name == '*': return self.serialize( {k: subfields for k in self.resource_fields}) field = getattr(self.__class__, name, None) if not field: raise ValueError('Unknown field {}'.format(name)) value = getattr(self, name) if value is not None: if isinstance( field, (db.ManyToManyField, peewee.ReverseRelationDescriptor)): value = [v.serialize(subfields) for v in value] elif isinstance(field, db.ForeignKeyField): value = value.serialize(subfields) elif isinstance(value, datetime): value = value.isoformat() elif isinstance(value, Point): value = value.geojson dest[name] = value return dest @property def as_resource(self): """Resource plus relations.""" # All fields and all first level relations fields. return self.serialize({'*': {}}) @property def as_version(self): """Resources plus relations references and metadata.""" return self.serialize({f: {} for f in self.versioned_fields}) @property def as_export(self): """Flat resources plus references. May be filtered or overrided.""" return self.serialize({'*': {}}) @property def status(self): return 'deleted' if self.deleted_at else 'active' @classmethod def select(cls, *selection): return super().select(*selection) @classmethod def raw_select(cls, *selection): return super().select(*selection) def mark_deleted(self): if self.deleted_at: raise ValueError('Resource already marked as deleted') self.ensure_no_reverse_relation() self.deleted_at = utcnow() self.increment_version() self.save() def ensure_no_reverse_relation(self): for name, field in self._meta.reverse_rel.items(): select = getattr(self, name) if getattr(select.model_class, 'deleted_at', None): select = select.where(select.model_class.deleted_at.is_null()) if select.count(): raise ResourceLinkedError( 'Resource still linked by `{}`'.format(name)) @classmethod def coerce(cls, id, identifier=None, level1=0): if isinstance(id, db.Model): instance = id else: if not identifier: identifier = 'id' # BAN id by default. if isinstance(id, str): *extra, id = id.split(':') if extra: identifier = extra[0] if identifier not in cls.identifiers + ['id', 'pk']: raise cls.DoesNotExist( "Invalid identifier {}".format(identifier)) elif isinstance(id, int): identifier = 'pk' try: if not hasattr(cls, 'auth') and level1 != 1: instance = cls.raw_select(cls._meta.model_class.pk).where( getattr(cls, identifier) == id).get() else: instance = cls.raw_select().where( getattr(cls, identifier) == id).get() except cls.DoesNotExist: # Is it an old identifier? from .versioning import Redirect redirects = Redirect.follow(cls.__name__, identifier, id) if redirects: if len(redirects) > 1: raise MultipleRedirectsError(identifier, id, redirects) raise RedirectError(identifier, id, redirects[0]) raise return instance
class NamedModel(Model): name = db.CharField(max_length=200) alias = db.ArrayField(db.CharField, null=True) def __str__(self): return self.name