class Blob(Model): """ Model for storing large file content. Files are stored on-disk, named after their uuid. Repository is located in instance folder/data/files. """ __tablename__ = "blob" query_class = BlobQuery id = Column(Integer(), primary_key=True, autoincrement=True) uuid = Column(UUID(), unique=True, nullable=False, default=uuid.uuid4) meta = Column(JSONDict(), nullable=False, default=dict) def __init__(self, value=None, *args, **kwargs): super(Blob, self).__init__(*args, **kwargs) if self.uuid is None: self.uuid = uuid.uuid4() if self.meta is None: self.meta = dict() if value is not None: self.value = value @property def file(self): """ Return :class:`pathlib.Path` object used for storing value """ from abilian.services.repository import session_repository as repository return repository.get(self, self.uuid) @property def size(self): """ Return size in bytes of value """ f = self.file return f.stat().st_size if f is not None else 0 @property def value(self): """ Binary value content """ v = self.file return v.open('rb').read() if v is not None else v @value.setter def value(self, value, encoding='utf-8'): """ Store binary content to applications's repository and update `self.meta['md5']`. :param:content: string, bytes, or any object with a `read()` method :param:encoding: encoding to use when content is unicode """ from abilian.services.repository import session_repository as repository repository.set(self, self.uuid, value) self.meta['md5'] = unicode(hashlib.md5(self.value).hexdigest()) if hasattr(value, 'filename'): filename = getattr(value, 'filename') if isinstance(filename, bytes): filename = filename.decode('utf-8') self.meta['filename'] = filename if hasattr(value, 'content_type'): self.meta['mimetype'] = getattr(value, 'content_type') @value.deleter def value(self): """ Remove value from repository """ from abilian.services.repository import session_repository as repository repository.delete(self, self.uuid) @property def md5(self): """ Return md5 from meta, or compute it if absent """ md5 = self.meta.get('md5') if md5 is None: md5 = unicode(hashlib.md5(self.value).hexdigest()) return md5 def __nonzero__(self): """ A blob is considered null if it has no file """ return self.file is not None and self.file.exists()
class Blob(Model): """Model for storing large file content. Files are stored on-disk, named after their uuid. Repository is located in instance folder/data/files. """ __tablename__ = "blob" id = Column(Integer(), primary_key=True, autoincrement=True) uuid = Column(UUID(), unique=True, nullable=False, default=uuid.uuid4) meta = Column(JSONDict(), nullable=False, default=dict) def __init__(self, value=None, *args, **kwargs): super().__init__(*args, **kwargs) if self.uuid is None: self.uuid = uuid.uuid4() if self.meta is None: self.meta = {} if value is not None: self.value = value @property def file(self) -> Optional[Path]: """Return :class:`pathlib.Path` object used for storing value.""" from abilian.services.repository import session_repository as repository # pyre-fixme[7]: Expected `Optional[Path]` but got `Optional[Union[Path, # object]]`. # pyre-fixme[6]: Expected `UUID` for 2nd param but got `Column`. return repository.get(self, self.uuid) @property def size(self) -> int: """Return size in bytes of value.""" f = self.file return f.stat().st_size if f is not None else 0 @property def value(self) -> bytes: """Binary value content.""" v = self.file # pyre-fixme[7]: Expected `bytes` but got `Optional[AnyStr]`. return v.open("rb").read() if v is not None else v @value.setter def value(self, value: bytes): """Store binary content to applications's repository and update `self.meta['md5']`. :param:content: bytes, or any object with a `read()` method :param:encoding: encoding to use when content is Unicode """ from abilian.services.repository import session_repository as repository repository.set(self, self.uuid, value) self.meta["md5"] = str(hashlib.md5(self.value).hexdigest()) if hasattr(value, "filename"): filename = value.filename if isinstance(filename, bytes): filename = filename.decode("utf-8") self.meta["filename"] = filename if hasattr(value, "content_type"): self.meta["mimetype"] = value.content_type @value.deleter def value(self) -> None: """Remove value from repository.""" from abilian.services.repository import session_repository as repository # pyre-fixme[6]: Expected `UUID` for 2nd param but got `Column`. repository.delete(self, self.uuid) @property def md5(self) -> str: """Return md5 from meta, or compute it if absent.""" md5 = self.meta.get("md5") if md5 is None: md5 = str(hashlib.md5(self.value).hexdigest()) return md5 def __bool__(self) -> bool: """A blob is considered falsy if it has no file.""" return self.file is not None and self.file.exists() # Py3k compat __nonzero__ = __bool__
class DummyModel2(Entity): list_attr = Column(JSONList()) dict_attr = Column(JSONDict()) uuid = Column(UUID()) query: Query
class Config(Entity): __tablename__ = "config" __indexable__ = False data = Column(JSONDict(), default=dict)
class OrgUnit(Entity): __tablename__ = "orgunit" __indexable__ = False query_class = OrgUnitQuery type = Column(Enum(*TYPE_ENUM, name="orgunit_type"), nullable=False) dn = Column(String, unique=True, index=True) nom = Column(Unicode, nullable=False, unique=True) sigle = Column(Unicode, default="", nullable=False) parent_id = Column(Integer, ForeignKey("orgunit.id")) parent = relationship( "OrgUnit", primaryjoin=remote(Entity.id) == foreign(parent_id), backref=backref("children", lazy="joined", cascade="all, delete-orphan"), ) wf_settings = Column(JSONDict(), default=dict) permettre_reponse_directe = Column(Boolean) permettre_soummission_directe = Column(Boolean) def __init__(self, **kw): super().__init__(**kw) self.wf_settings = {} def __unicode__(self): return f"<OrgUnit type='{self.type}' nom='{self.nom}' id={self.id}>" __str__ = __unicode__ # def __repr__(self): # return unicode(self).encode('utf8') @property def sigle_ou_nom(self) -> str: return self.sigle or self.nom @property def depth(self) -> int: if self.type == EQUIPE: return 4 if self.type == DEPARTEMENT: return 3 if self.type == LABORATOIRE: return 2 if self.type == UFR: return 1 if self.type == POLE_DE_RECHERCHE: return 0 # Should not happen return 0 @property def path(self) -> list[str]: t = [""] * 5 for p in self.parents + [self]: t[p.depth] = p.nom return t @property def parents(self) -> list[OrgUnit]: p = self result = [] while True: p = p.parent if not p: break result.append(p) result.reverse() return result def descendants(self) -> list[OrgUnit]: if self.type == EQUIPE: return [] if self.type == DEPARTEMENT: return list(self.children) children = self.children result = list(children) for c in children: result += c.descendants() return result def get_contacts_dgrtt(self): from .mapping_dgrtt import MappingDgrtt return MappingDgrtt.query.get_for_ou(self) def get_members_with_role(self, role_type: RoleType | None) -> list[Profile]: roles = roles_service.get_roles(role_type=role_type, target=self) result = [r.profile for r in roles if r.profile] return result def get_directeurs(self) -> list[Profile]: from .roles import RoleType result = self.get_members_with_role(RoleType.DIRECTION) assert all(p.has_role("directeur") for p in result) # On met le "vrai" directeur en premier result = [p for p in result if p.is_directeur ] + [p for p in result if not p.is_directeur] return result def get_gestionnaires(self) -> list[Profile]: from .roles import RoleType result = self.get_members_with_role(RoleType.GDL) assert all(p.has_role("gestionnaire") for p in result) return result def get_administrateurs(self) -> list[Profile]: from .roles import RoleType result = self.get_members_with_role(RoleType.ALL) assert all(p.has_role("all") for p in result) return result @property def direction(self) -> list[Profile]: return self.get_directeurs() @property def gestionnaires(self) -> list[Profile]: return self.get_gestionnaires() @property def administrateurs(self): return self.get_administrateurs() def set_roles(self, users: list[Profile], role_type: RoleType) -> None: roles = roles_service.get_roles(role_type=role_type, target=self) for role in roles: db.session.delete(role) db.session.flush() for user in users: roles_service.grant_role(user, role_type, self) @property def directeur(self) -> Profile | None: direction = self.get_directeurs() direction = [d for d in direction if d.is_directeur] return toolz.get(0, direction, None) @property def adresse(self): if self.directeur and self.directeur.adresse: return self.directeur.adresse return "" def validate(self) -> None: if self.type == POLE_DE_RECHERCHE: assert self.parent is None elif self.type == UFR: assert self.parent.type == POLE_DE_RECHERCHE elif self.type == LABORATOIRE: assert self.parent.type in (POLE_DE_RECHERCHE, UFR) elif self.type == DEPARTEMENT: assert self.parent.type == LABORATOIRE elif self.type == EQUIPE: assert self.parent.type in (LABORATOIRE, DEPARTEMENT) elif self.type == BUREAU_DGRTT: assert self.parent is None else: raise AssertionError() def get_labo(self) -> OrgUnit | None: if self.type == LABORATOIRE: return self if not self.parent: return None if self.parent.type == LABORATOIRE: return self.parent if self.parent.parent.type == LABORATOIRE: return self.parent.parent raise AssertionError("Should not happen") @property def laboratoire(self) -> OrgUnit | None: try: return self.get_labo() except Exception: return None @property def equipe(self) -> OrgUnit | None: if self.type == EQUIPE: return self return None @property def departement(self) -> OrgUnit | None: if self.type == DEPARTEMENT: return self if self.type == EQUIPE and self.parent.type == DEPARTEMENT: return self.parent return None @property def ufr(self) -> OrgUnit | None: if self.type == POLE_DE_RECHERCHE: return None if self.type == UFR: return self assert self.laboratoire labo: OrgUnit = self.laboratoire parent = labo.parent if parent.type == UFR: return parent return None @property def pole(self) -> OrgUnit | None: if self.type == POLE_DE_RECHERCHE: return self if self.type == UFR: return self.parent assert self.laboratoire labo: OrgUnit = self.laboratoire parent: OrgUnit = labo.parent if parent.type == POLE_DE_RECHERCHE: return parent return parent.parent def get_membres(self) -> list[Profile]: if self.type not in [LABORATOIRE, EQUIPE, DEPARTEMENT]: return [] labo = self.get_labo() if labo: membres_du_labo = {m for m in labo.membres if m.active} else: membres_du_labo = set() if self.type == LABORATOIRE: membres = list(membres_du_labo) elif self.type == EQUIPE: membres = [m for m in membres_du_labo if m.sous_structure == self] else: # self.type == DEPARTEMENT membres_dict = { m for m in membres_du_labo if m.sous_structure == self } for equipe in self.children: membres_dict.update(equipe.get_membres()) membres = list(membres_dict) def sorter(profile: Profile) -> tuple[str, str]: return profile.nom, profile.prenom return sorted(membres, key=sorter) def wf_must_validate(self, type: str) -> bool: assert type in [LABORATOIRE, DEPARTEMENT, EQUIPE] if self.type == LABORATOIRE: return True wf_settings = self.wf_settings if type == LABORATOIRE: return wf_settings.get("validation_labo", True) elif type == DEPARTEMENT: return wf_settings.get("validation_dept", True) else: # type == EQUIPE return wf_settings.get("validation_equipe", True)
class Demande(IdMixin, TimestampedMixin, Indexable, db.Model): __tablename__ = "demande" query_class = DemandeQuery type = Column(Enum(*TYPE_ENUM, name="demande_type"), nullable=False, index=True) nom = Column(Unicode, default="", nullable=False, info=SEARCHABLE) name = Column(Unicode, default="", nullable=False, info=SEARCHABLE) no_infolab = Column( Unicode, default="", server_default="", nullable=False, info=SEARCHABLE ) no_eotp = Column( Unicode, default="", server_default="", nullable=False, info=SEARCHABLE ) data = Column(JSONDict(), default=dict) past_versions = Column(JSONList(), default=list) form_state = Column(JSONDict(), default=dict) attachments = Column(JSONDict(), default=dict) feuille_cout = Column(JSONDict(), default=dict) #: Date de validation par la hiérarchie date_effective = Column(Date, nullable=True) #: Seules les demandes actives apparaissent dans le workflow. #: Les autres sont considérées comme archivées. active = Column(Boolean, default=True, nullable=False) editable = Column(Boolean, default=True, nullable=False) # Les acteurs de la demande: #: id du porteur de la demande porteur_id = Column(Integer, ForeignKey(Profile.id), index=True) #: Le porteur de la demande porteur = relationship(Profile, foreign_keys=[porteur_id]) #: id du gestionnaire de la demande (GDL) gestionnaire_id = Column(Integer, ForeignKey(Profile.id), index=True) #: Le gestionnaire de la demande gestionnaire = relationship(Profile, foreign_keys=[gestionnaire_id]) #: id du gestionnaire de la demande (GDL) contact_dgrtt_id = Column(Integer, ForeignKey(Profile.id), index=True) #: Le gestionnaire de la demande contact_dgrtt = relationship(Profile, foreign_keys=[contact_dgrtt_id]) structure_id = Column(Integer, ForeignKey(OrgUnit.id), index=True) structure = relationship(OrgUnit, foreign_keys=[structure_id]) # Variables liées au workflow wf_state = Column(WF_ENUM, default=EN_EDITION.id, nullable=False, index=True) wf_date_derniere_action = Column(DateTime, nullable=False) #: nombre de jours de retard (0 si pas de retard) wf_retard = Column(Integer, nullable=False, default=0) wf_history = Column(JSONList(), default=list) wf_data = Column(JSONDict(), default=dict) wf_stage_id = Column(Integer, ForeignKey(OrgUnit.id), index=True, nullable=True) wf_stage = relationship( OrgUnit, primaryjoin=remote(Entity.id) == foreign(wf_stage_id) ) #: id de la personne responsable de la tâche en cours wf_current_owner_id = Column( Integer, ForeignKey(Profile.id), index=True, nullable=True ) #: la personne responsable de la tâche en cours wf_current_owner = relationship( Profile, primaryjoin=remote(Entity.id) == foreign(wf_current_owner_id) ) __mapper_args__ = {"polymorphic_identity": "", "polymorphic_on": type} def __init__(self, **kw): super().__init__(**kw) assert self.porteur or self.gestionnaire if self.porteur: self.structure = self.porteur.structure self.data = {} self.attachments = {} self.form_state = {"fields": []} self.versions = [] self.wf_state = EN_EDITION.id self.wf_history = [] self.wf_data = {} if not self.created_at: self.created_at = datetime.utcnow() self.wf_date_derniere_action = self.created_at def __repr__(self) -> str: return f"<{self.__class__.__name__} with id={self.id}>" def log_creation(self, actor: Profile) -> None: if not self.wf_history: message = f"Demande créée par l'utilisateur {actor.full_name}" log_entry = { "date": datetime.now().strftime("%d %b %Y %H:%M:%S"), "actor_id": actor.id, "message": message, "note": "", } self.wf_history = [log_entry] def clone(self) -> Demande: nouvelle_demande = Demande( nom=self.nom, type=self.type, wf_state=EN_EDITION.id, porteur=self.porteur, gestionnaire=self.gestionnaire, ) nouvelle_demande.data = self.data.copy() nouvelle_demande.form_state = self.form_state.copy() return nouvelle_demande @property def date_debut(self): return None @property def age(self): if not self.date_effective: return 0 dt = date.today() - self.date_effective return int(dt.days) @property def retard(self): if not self.wf_date_derniere_action or not self.active: return 0 # TODO: implémenter la logique de jours ouvrés dt = datetime.utcnow() - self.wf_date_derniere_action return int(dt.days) def update_retard(self): # TODO: implémenter la logique de jours ouvrés dt = datetime.utcnow() - self.wf_date_derniere_action self.wf_retard = int(dt.days) def nom_par_defaut(self) -> str: return self.type + " sans nom" # # Accessors / properties # def __getattr__(self, name: str) -> Any: if name.startswith("_"): raise AttributeError(f"object has no attribute '{name}'") data = getattr(self, "data", None) if data and name in data: return data[name] raise AttributeError(f"object has no attribute '{name}'") def has_same_data(self, data: dict[str, Any]) -> bool: """Return True if the current version is the same as the given data.""" current_data = self.data for k in set(current_data.keys()) | set(data.keys()): if k in ["csrf_token"]: continue if k.startswith("html-"): continue current_value = current_data.get(k) new_value = data.get(k) if current_value != new_value: return False return True @property def contact(self) -> Profile: return self.gestionnaire or self.porteur @property def directeur_name(self) -> str: if self.laboratoire and self.laboratoire.directeur: return self.laboratoire.directeur.full_name return "" @property def laboratoire(self) -> OrgUnit: structure = self.structure if not structure: if self.porteur: self.structure = self.porteur.structure elif self.gestionnaire: self.structure = self.gestionnaire.laboratoire structure = self.structure return structure.laboratoire @property def owners(self) -> Collection[Profile]: owners = [] if self.gestionnaire: owners.append(self.gestionnaire) if self.porteur: owners.append(self.porteur) return owners @property def contributeurs(self) -> Collection[Profile]: return [] def is_editable_by(self, user: Profile): # FIXME return True # return self.editable and user in [self.gestionnaire, self.porteur] def feuille_cout_is_editable_by(self, user: Profile) -> bool: return self.active and user in [ self.gestionnaire, self.porteur, self.contact_dgrtt, ] # # Workflow # def get_workflow(self, user: Profile | None = None) -> LabsterWorkflow: return LabsterWorkflow(self, user) def get_state(self, user: Profile | None = None) -> State: workflow = self.get_workflow(user) return workflow.current_state() def current_owners(self) -> list[Profile]: return self.get_workflow().current_owners() @property def date_soumission(self) -> date | None: for entry in self.wf_history: if entry.get("transition") == "SOUMETTRE": return dateutil.parser.parse(entry["date"]).date() return None @property def date_finalisation(self) -> date | None: final_states = ["CONFIRMER_FINALISATION_DGRTT", "ABANDONNER", "REJETER_DGRTT"] for entry in self.wf_history: if entry.get("transition") in final_states: return dateutil.parser.parse(entry["date"]).date() return None # # Data validation / manipulation # def validate(self) -> Validation: return Validation(self, self.get_errors(), self.get_extra_errors()) def get_errors(self) -> list[Any]: errors = [] form_state = self.form_state fields = form_state["fields"] for field_name, field_value in self.data.items(): if field_name not in fields: continue field = fields[field_name] visible = field.get("visible") required = field.get("required") if visible and required and not field_value: errors.append(field["name"]) continue return errors def get_extra_errors(self): return [] def is_valid(self) -> bool: validation = self.validate() return validation.ok @property def errors(self): return self.validate().errors def update_data(self, data: dict) -> None: self.increase_version() self.data.update(data) self.update_nom() self.post_update() # new_name = data.get('nom', None) # if new_name is not None and new_name != self.name: # self.name = new_name def post_update(self) -> None: new_porteur_uid = self.data.get("porteur", None) if new_porteur_uid is not None: try: new_porteur = Profile.query.get_by_uid(new_porteur_uid) self.porteur = new_porteur self.structure = new_porteur.structure except Exception: # TODO: better solution to deal with tests. pass def increase_version(self) -> None: if self.data: self.past_versions.append( (self.data, datetime.utcnow().strftime("%d %b %Y %H:%M:%S")) ) # Pièces jointes @property def pieces_jointes(self) -> list[dict]: result = [] for k, v in sorted(self.attachments.items()): if isinstance(v, dict): creator = Profile.query.get_by_uid(v["creator"]) d = {"id": v["id"], "name": k, "creator": creator} date_str = v.get("date") if date_str: d["date"] = iso8601.parse_date(date_str) else: d["date"] = None else: d = {"id": v, "name": k, "creator": None, "date": None} id = d["id"] blob = Blob.query.get(id) if blob: result.append(d) return result