user.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. from __future__ import annotations
  2. from typing import TYPE_CHECKING
  3. from datetime import datetime, timezone
  4. from flask_security import UserMixin, RoleMixin
  5. import pandas as pd
  6. from sqlalchemy import select, func
  7. from sqlalchemy.orm import relationship, backref
  8. from sqlalchemy import Boolean, DateTime, Column, Integer, String, ForeignKey
  9. from sqlalchemy.ext.hybrid import hybrid_property
  10. from timely_beliefs import utils as tb_utils
  11. from flexmeasures.data import db
  12. from flexmeasures.data.models.annotations import (
  13. Annotation,
  14. AccountAnnotationRelationship,
  15. to_annotation_frame,
  16. )
  17. from flexmeasures.data.models.parsing_utils import parse_source_arg
  18. from flexmeasures.auth.policy import AuthModelMixin, CONSULTANT_ROLE, ACCOUNT_ADMIN_ROLE
  19. if TYPE_CHECKING:
  20. from flexmeasures.data.models.data_sources import DataSource
  21. class RolesAccounts(db.Model):
  22. __tablename__ = "roles_accounts"
  23. id = Column(Integer(), primary_key=True)
  24. account_id = Column("account_id", Integer(), ForeignKey("account.id"))
  25. role_id = Column("role_id", Integer(), ForeignKey("account_role.id"))
  26. __table_args__ = (
  27. db.UniqueConstraint(
  28. "role_id",
  29. "account_id",
  30. name="roles_accounts_role_id_key",
  31. ),
  32. )
  33. class AccountRole(db.Model):
  34. __tablename__ = "account_role"
  35. id = Column(Integer(), primary_key=True)
  36. name = Column(String(80), unique=True)
  37. description = Column(String(255))
  38. def __repr__(self):
  39. return "<AccountRole:%s (ID:%s)>" % (self.name, self.id)
  40. class Account(db.Model, AuthModelMixin):
  41. """
  42. Account of a tenant on the server.
  43. Bundles Users as well as GenericAssets.
  44. """
  45. __tablename__ = "account"
  46. id = Column(Integer, primary_key=True)
  47. name = Column(String(100), default="", unique=True)
  48. account_roles = relationship(
  49. "AccountRole",
  50. secondary="roles_accounts",
  51. backref=backref("accounts", lazy="dynamic"),
  52. )
  53. primary_color = Column(String(7), default=None)
  54. secondary_color = Column(String(7), default=None)
  55. logo_url = Column(String(255), default=None)
  56. annotations = db.relationship(
  57. "Annotation",
  58. secondary="annotations_accounts",
  59. backref=db.backref("accounts", lazy="dynamic"),
  60. )
  61. # Setup self-referential relationship between consultancy account and consultancy client account
  62. consultancy_account_id = Column(
  63. Integer, db.ForeignKey("account.id"), default=None, nullable=True
  64. )
  65. consultancy_client_accounts = db.relationship(
  66. "Account", back_populates="consultancy_account"
  67. )
  68. consultancy_account = db.relationship(
  69. "Account", back_populates="consultancy_client_accounts", remote_side=[id]
  70. )
  71. def __repr__(self):
  72. return "<Account %s (ID:%s)>" % (self.name, self.id)
  73. def __acl__(self):
  74. """
  75. Only account admins can create things in the account (e.g. users or assets).
  76. Consultants (i.e. users with the consultant role) can read things in the account,
  77. but only if their organisation is set as a consultancy for the given account.
  78. Within same account, everyone can read and update.
  79. Creation and deletion of accounts are left to site admins in CLI.
  80. """
  81. read_access = [f"account:{self.id}"]
  82. if self.consultancy_account_id is not None:
  83. read_access.append(
  84. (f"account:{self.consultancy_account_id}", f"role:{CONSULTANT_ROLE}")
  85. )
  86. return {
  87. "create-children": (f"account:{self.id}", f"role:{ACCOUNT_ADMIN_ROLE}"),
  88. "read": read_access,
  89. "update": f"account:{self.id}",
  90. }
  91. def get_path(self, separator: str = ">"):
  92. return self.name
  93. def has_role(self, role: str | AccountRole) -> bool:
  94. """Returns `True` if the account has the specified role.
  95. :param role: An account role name or `AccountRole` instance"""
  96. if isinstance(role, str):
  97. return role in (role.name for role in self.account_roles)
  98. else:
  99. return role in self.account_roles
  100. def search_annotations(
  101. self,
  102. annotation_starts_after: datetime | None = None, # deprecated
  103. annotations_after: datetime | None = None,
  104. annotation_ends_before: datetime | None = None, # deprecated
  105. annotations_before: datetime | None = None,
  106. source: (
  107. DataSource | list[DataSource] | int | list[int] | str | list[str] | None
  108. ) = None,
  109. as_frame: bool = False,
  110. ) -> list[Annotation] | pd.DataFrame:
  111. """Return annotations assigned to this account.
  112. :param annotations_after: only return annotations that end after this datetime (exclusive)
  113. :param annotations_before: only return annotations that start before this datetime (exclusive)
  114. """
  115. # todo: deprecate the 'annotation_starts_after' argument in favor of 'annotations_after' (announced v0.11.0)
  116. annotations_after = tb_utils.replace_deprecated_argument(
  117. "annotation_starts_after",
  118. annotation_starts_after,
  119. "annotations_after",
  120. annotations_after,
  121. required_argument=False,
  122. )
  123. # todo: deprecate the 'annotation_ends_before' argument in favor of 'annotations_before' (announced v0.11.0)
  124. annotations_before = tb_utils.replace_deprecated_argument(
  125. "annotation_ends_before",
  126. annotation_ends_before,
  127. "annotations_before",
  128. annotations_before,
  129. required_argument=False,
  130. )
  131. parsed_sources = parse_source_arg(source)
  132. query = (
  133. select(Annotation)
  134. .join(AccountAnnotationRelationship)
  135. .filter(
  136. AccountAnnotationRelationship.account_id == self.id,
  137. AccountAnnotationRelationship.annotation_id == Annotation.id,
  138. )
  139. )
  140. if annotations_after is not None:
  141. query = query.filter(
  142. Annotation.end > annotations_after,
  143. )
  144. if annotations_before is not None:
  145. query = query.filter(
  146. Annotation.start < annotations_before,
  147. )
  148. if parsed_sources:
  149. query = query.filter(
  150. Annotation.source.in_(parsed_sources),
  151. )
  152. annotations = db.session.scalars(query).all()
  153. return to_annotation_frame(annotations) if as_frame else annotations
  154. @property
  155. def number_of_assets(self):
  156. from flexmeasures.data.models.generic_assets import GenericAsset
  157. return db.session.execute(
  158. select(func.count()).where(GenericAsset.account_id == self.id)
  159. ).scalar_one_or_none()
  160. @property
  161. def number_of_users(self):
  162. return db.session.execute(
  163. select(func.count()).where(User.account_id == self.id)
  164. ).scalar_one_or_none()
  165. class RolesUsers(db.Model):
  166. __tablename__ = "roles_users"
  167. id = Column(Integer(), primary_key=True)
  168. user_id = Column("user_id", Integer(), ForeignKey("fm_user.id"))
  169. role_id = Column("role_id", Integer(), ForeignKey("role.id"))
  170. __table_args__ = (
  171. db.UniqueConstraint(
  172. "role_id",
  173. "user_id",
  174. name="roles_users_role_id_key",
  175. ),
  176. )
  177. class Role(db.Model, RoleMixin):
  178. __tablename__ = "role"
  179. id = Column(Integer(), primary_key=True)
  180. name = Column(String(80), unique=True)
  181. description = Column(String(255))
  182. def __repr__(self):
  183. return "<Role:%s (ID:%s)>" % (self.name, self.id)
  184. class User(db.Model, UserMixin, AuthModelMixin):
  185. """
  186. We use the flask security UserMixin, which does include functionality,
  187. but not the fields (those are in flask_security/models::FsUserMixin).
  188. We went with a pick&choose approach. This gives us more freedom, e.g.
  189. to choose our own table name or add logic around the activation status.
  190. If we add new FS functionality (e.g. 2FA), the fields needed for that
  191. need to be added here.
  192. """
  193. __tablename__ = "fm_user"
  194. id = Column(Integer, primary_key=True)
  195. email = Column(String(255), unique=True)
  196. username = Column(String(255), unique=True)
  197. password = Column(String(255))
  198. # Last time the user logged in (provided credentials to get access)
  199. last_login_at = Column(DateTime())
  200. # Last time the user made a request (authorized by session or auth token)
  201. last_seen_at = Column(DateTime())
  202. # How often have they logged in?
  203. login_count = Column(Integer)
  204. active = Column(Boolean())
  205. # Faster token checking
  206. fs_uniquifier = Column(String(64), unique=True, nullable=False)
  207. timezone = Column(String(255), default="Europe/Amsterdam")
  208. account_id = Column(Integer, db.ForeignKey("account.id"), nullable=False)
  209. account = db.relationship("Account", backref=db.backref("users", lazy=True))
  210. flexmeasures_roles = relationship(
  211. "Role",
  212. secondary="roles_users",
  213. backref=backref("users", lazy="dynamic"),
  214. )
  215. def __repr__(self):
  216. return "<User %s (ID:%s)>" % (self.username, self.id)
  217. def __acl__(self):
  218. """
  219. Within same account, everyone can read.
  220. Only the user themselves, consultants or account-admins can edit their user record.
  221. Creation and deletion are left to site admins in CLI.
  222. """
  223. return {
  224. "read": f"account:{self.account_id}",
  225. "update": [
  226. f"user:{self.id}",
  227. (f"account:{self.account_id}", f"role:{ACCOUNT_ADMIN_ROLE}"),
  228. (
  229. f"account:{self.account.consultancy_account_id}",
  230. f"role:{CONSULTANT_ROLE}",
  231. ),
  232. ],
  233. }
  234. @property
  235. def is_authenticated(self) -> bool:
  236. """We are overloading this, so it also considers being active.
  237. Inactive users can by definition not be authenticated."""
  238. return super(UserMixin, self).is_authenticated and self.active
  239. @hybrid_property
  240. def roles(self):
  241. """The roles attribute is being used by Flask-Security in the @roles_required decorator (among others).
  242. With this little overload fix, it will only return the user's roles if they are authenticated.
  243. We do this to prevent that if a user is logged in while the admin deactivates them, their session would still work.
  244. In effect, we strip unauthenticated users from their roles. To read roles of an unauthenticated user
  245. (e.g. being inactive), use the `flexmeasures_roles` attribute.
  246. If our auth model has moved to an improved way, e.g. requiring modern tokens, we should consider relaxing this.
  247. Note: This needed to become a hybrid property when moving to Flask-Security 3.4
  248. """
  249. if not self.is_authenticated and self is not User:
  250. return []
  251. else:
  252. return self.flexmeasures_roles
  253. @roles.setter
  254. def roles(self, new_roles):
  255. """See comment in roles property why we overload."""
  256. self.flexmeasures_roles = new_roles
  257. def has_role(self, role: str | Role) -> bool:
  258. """Returns `True` if the user identifies with the specified role.
  259. Overwritten from flask_security.core.UserMixin.
  260. :param role: A role name or `Role` instance"""
  261. if isinstance(role, str):
  262. return role in (role.name for role in self.flexmeasures_roles)
  263. else:
  264. return role in self.flexmeasures_roles
  265. def remember_login(the_app, user):
  266. """We do not use the tracking feature of flask_security, but this basic meta data are quite handy to know"""
  267. user.last_login_at = datetime.now(timezone.utc)
  268. if user.login_count is None:
  269. user.login_count = 0
  270. user.login_count = user.login_count + 1
  271. def remember_last_seen(user):
  272. """Update the last_seen field"""
  273. if user is not None and user.is_authenticated:
  274. user.last_seen_at = datetime.now(timezone.utc)
  275. db.session.add(user)
  276. db.session.commit()
  277. def is_user(o) -> bool:
  278. """True if object is or proxies a User, False otherwise.
  279. Takes into account that object can be of LocalProxy type, and
  280. uses get_current_object to get the underlying (User) object.
  281. """
  282. return isinstance(o, User) or (
  283. hasattr(o, "_get_current_object") and isinstance(o._get_current_object(), User)
  284. )