diff --git a/app/api/users.py b/app/api/users.py index 4fe895107a..3bbc96d338 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -7,7 +7,7 @@ """ ScoDoc 9 API : accès aux utilisateurs """ - +import datetime from flask import g, request from flask_json import as_json @@ -85,6 +85,20 @@ def users_info_query(): return [user.to_dict() for user in query] +def _is_allowed_user_edit(args: dict) -> tuple[bool, str]: + "Vrai si on peut" + if "cas_id" in args and not current_user.has_permission( + Permission.UsersChangeCASId + ): + return False, "non autorise a changer cas_id" + + if not current_user.is_administrator(): + for field in ("cas_allow_login", "cas_allow_scodoc_login"): + if field in args: + return False, f"non autorise a changer {field}" + return True, "" + + @bp.route("/user/create", methods=["POST"]) @api_web_bp.route("/user/create", methods=["POST"]) @login_required @@ -95,21 +109,22 @@ def user_create(): """Création d'un utilisateur The request content type should be "application/json": { - "user_name": str, + "active":bool (default True), "dept": str or null, "nom": str, "prenom": str, - "active":bool (default True) + "user_name": str, + ... } """ - data = request.get_json(force=True) # may raise 400 Bad Request - user_name = data.get("user_name") + args = request.get_json(force=True) # may raise 400 Bad Request + user_name = args.get("user_name") if not user_name: return json_error(404, "empty user_name") user = User.query.filter_by(user_name=user_name).first() if user: return json_error(404, f"user_create: user {user} already exists\n") - dept = data.get("dept") + dept = args.get("dept") if dept == "@all": dept = None allowed_depts = current_user.get_depts_with_permission(Permission.UsersAdmin) @@ -119,10 +134,12 @@ def user_create(): Departement.query.filter_by(acronym=dept).first() is None ): return json_error(404, "user_create: departement inexistant") - nom = data.get("nom") - prenom = data.get("prenom") - active = scu.to_bool(data.get("active", True)) - user = User(user_name=user_name, active=active, dept=dept, nom=nom, prenom=prenom) + args["dept"] = dept + ok, msg = _is_allowed_user_edit(args) + if not ok: + return json_error(403, f"user_create: {msg}") + user = User() + user.from_dict(args, new_user=True) db.session.add(user) db.session.commit() return user.to_dict() @@ -142,13 +159,14 @@ def user_edit(uid: int): "nom": str, "prenom": str, "active":bool + ... } """ - data = request.get_json(force=True) # may raise 400 Bad Request + args = request.get_json(force=True) # may raise 400 Bad Request user: User = User.query.get_or_404(uid) # L'utilisateur doit avoir le droit dans le département de départ et celui d'arrivée orig_dept = user.dept - dest_dept = data.get("dept", False) + dest_dept = args.get("dept", False) if dest_dept is not False: if dest_dept == "@all": dest_dept = None @@ -164,10 +182,11 @@ def user_edit(uid: int): return json_error(404, "user_edit: departement inexistant") user.dept = dest_dept - user.nom = data.get("nom", user.nom) - user.prenom = data.get("prenom", user.prenom) - user.active = scu.to_bool(data.get("active", user.active)) + ok, msg = _is_allowed_user_edit(args) + if not ok: + return json_error(403, f"user_edit: {msg}") + user.from_dict(args) db.session.add(user) db.session.commit() return user.to_dict() diff --git a/app/auth/models.py b/app/auth/models.py index dcf762a2d1..38bad09742 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -21,7 +21,7 @@ from werkzeug.security import generate_password_hash, check_password_hash import jwt from app import db, email, log, login -from app.models import Departement +from app.models import Departement, ScoDocModel from app.models import SHORT_STR_LEN, USERNAME_STR_LEN from app.models.config import ScoDocSiteConfig from app.scodoc.sco_exceptions import ScoValueError @@ -59,7 +59,7 @@ def invalid_user_name(user_name: str) -> bool: ) -class User(UserMixin, db.Model): +class User(UserMixin, db.Model, ScoDocModel): """ScoDoc users, handled by Flask / SQLAlchemy""" id = db.Column(db.Integer, primary_key=True) @@ -121,7 +121,7 @@ class User(UserMixin, db.Model): # check login: if kwargs.get("user_name") and invalid_user_name(kwargs["user_name"]): raise ValueError(f"invalid user_name: {kwargs['user_name']}") - super(User, self).__init__(**kwargs) + super().__init__(**kwargs) # Ajoute roles: if ( not self.roles @@ -251,12 +251,13 @@ class User(UserMixin, db.Model): "cas_last_login": self.cas_last_login.isoformat() + "Z" if self.cas_last_login else None, + "edt_id": self.edt_id, "status_txt": "actif" if self.active else "fermé", "last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None, - "nom": (self.nom or ""), # sco8 - "prenom": (self.prenom or ""), # sco8 + "nom": self.nom or "", + "prenom": self.prenom or "", "roles_string": self.get_roles_string(), # eg "Ens_RT, Ens_Info" - "user_name": self.user_name, # sco8 + "user_name": self.user_name, # Les champs calculés: "nom_fmt": self.get_nom_fmt(), "prenom_fmt": self.get_prenom_fmt(), @@ -270,28 +271,34 @@ class User(UserMixin, db.Model): data["email_institutionnel"] = self.email_institutionnel or "" return data + @classmethod + def convert_dict_fields(cls, args: dict) -> dict: + """Convert fields in the given dict. No other side effect. + args: dict with args in application. + returns: dict to store in model's db. + Convert boolean values to bools. + """ + args_dict = args + if "date_expiration" in args: + date_expiration = args.get("date_expiration") + if isinstance(date_expiration, str): + args["date_expiration"] = ( + datetime.datetime.fromisoformat(date_expiration) + if date_expiration + else None + ) + + for field in ("active", "cas_allow_login", "cas_allow_scodoc_login"): + if field in args: + args_dict[field] = scu.to_bool(args.get(field)) + return args_dict + def from_dict(self, data: dict, new_user=False): """Set users' attributes from given dict values. Roles must be encoded as "roles_string", like "Ens_RT, Secr_CJ" + Does not check permissions here. """ - for field in [ - "nom", - "prenom", - "dept", - "active", - "email", - "email_institutionnel", - "date_expiration", - "cas_id", - ]: - if field in data: - setattr(self, field, data[field] or None) - # required boolean fields - for field in [ - "cas_allow_login", - "cas_allow_scodoc_login", - ]: - setattr(self, field, scu.to_bool(data.get(field, False))) + super().from_dict(data, excluded=("user_name", "roles_string")) if new_user: if "user_name" in data: diff --git a/app/models/__init__.py b/app/models/__init__.py index c4e04bd6b7..76967ed429 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -78,9 +78,11 @@ class ScoDocModel: # virtual, by default, do nothing return args - def from_dict(self, args: dict): + def from_dict(self, args: dict, excluded: set[str] = None): "Update object's fields given in dict. Add to session but don't commit." - args_dict = self.convert_dict_fields(self.filter_model_attributes(args)) + args_dict = self.convert_dict_fields( + self.filter_model_attributes(args, excluded=excluded) + ) for key, value in args_dict.items(): if hasattr(self, key): setattr(self, key, value) diff --git a/app/scodoc/sco_users.py b/app/scodoc/sco_users.py index a102700a54..fad28632e3 100644 --- a/app/scodoc/sco_users.py +++ b/app/scodoc/sco_users.py @@ -102,7 +102,7 @@ def index_html( {menu_roles} - + """ ) @@ -204,7 +204,7 @@ def list_users( "cas_allow_scodoc_login", "cas_last_login", ] - columns_ids.append("email_institutionnel") + columns_ids += ["email_institutionnel", "edt_id"] title = "Utilisateurs définis dans ScoDoc" tab = GenTable( @@ -227,6 +227,7 @@ def list_users( "cas_allow_login": "CAS autorisé", "cas_allow_scodoc_login": "Cnx sans CAS", "cas_last_login": "Dernier login CAS", + "edt_id": "Identifiant emploi du temps", }, caption=title, page_title="title", diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 0f9072c81c..f96efa8b4b 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -4347,6 +4347,10 @@ table.dataTable td.group { background: #fff; } +#zonePartitions .edt_id { + color: rgb(85, 255, 24); +} + /* ------------- Nouveau tableau recap ------------ */ div.table_recap { margin-top: 6px; @@ -4856,7 +4860,3 @@ div.cas_etat_certif_ssl { font-style: italic; color: rgb(231, 0, 0); } - -.edt_id { - color: rgb(85, 255, 24); -} diff --git a/app/templates/auth/user_info_page.j2 b/app/templates/auth/user_info_page.j2 index 8a6cb7f6e4..e21da1650c 100644 --- a/app/templates/auth/user_info_page.j2 +++ b/app/templates/auth/user_info_page.j2 @@ -18,6 +18,7 @@ Prénom : {{user.prenom or ""}}
Mail : {{user.email}}
Mail institutionnel: {{user.email_institutionnel or ""}}
+ Identifiant EDT: {{user.edt_id or ""}}
Rôles : {{user.get_roles_string()}}
Dept : {{user.dept or ""}}
{% if user.passwd_temp or user.password_scodoc7 %} diff --git a/app/views/users.py b/app/views/users.py index 3d07b05a3f..3f360c458b 100644 --- a/app/views/users.py +++ b/app/views/users.py @@ -450,6 +450,17 @@ def create_user_form(user_name=None, edit=0, all_roles=True): "readonly": edit_only_roles, }, ), + ( + "edt_id", + { + "title": "Identifiant sur l'emploi du temps", + "input_type": "text", + "explanation": """id du compte utilisateur sur l'emploi du temps + ou l'annuaire de l'établissement (par défaut, l'e-mail institutionnel )""", + "size": 36, + "allow_null": True, + }, + ), ] if not edit: # options création utilisateur descr += [ diff --git a/tests/api/test_api_users.py b/tests/api/test_api_users.py index 1421a29cf4..7785655392 100644 --- a/tests/api/test_api_users.py +++ b/tests/api/test_api_users.py @@ -88,15 +88,17 @@ def test_edit_users(api_admin_headers): # Change le dept et rend inactif user = POST_JSON( f"/user/{user['id']}/edit", - {"active": False, "dept": "TAPI"}, + {"active": False, "dept": "TAPI", "edt_id": "GGG"}, headers=admin_h, ) assert user["dept"] == "TAPI" assert user["active"] is False + assert user["edt_id"] == "GGG" user = GET(f"/user/{user['id']}", headers=admin_h) assert user["nom"] == "Toto" assert user["dept"] == "TAPI" assert user["active"] is False + assert user["edt_id"] == "GGG" def test_roles(api_admin_headers): diff --git a/tests/unit/test_users.py b/tests/unit/test_users.py index 789ebc5ca9..8f4d8a84fe 100644 --- a/tests/unit/test_users.py +++ b/tests/unit/test_users.py @@ -123,3 +123,31 @@ def test_create_delete(test_client): db.session.commit() ul = User.query.filter_by(prenom="Pierre").all() assert len(ul) == 1 + + +def test_edit(test_client): + "test edition object utlisateur" + args = { + "user_name": "Tonari", + "prenom": "No Totoro", + "edt_id": "totorito", + "cas_allow_login": 1, # boolean + "irrelevant": "..", # intentionnellement en dehors des attributs + } + u = User() + u.from_dict(args) + db.session.add(u) + db.session.commit() + db.session.refresh(u) + assert u.edt_id == "totorito" + assert u.nom is None + assert u.cas_allow_login is True + d = u.to_dict() + assert d["nom"] == "" + args["cas_allow_login"] = 0 + u.from_dict(args) + db.session.commit() + db.session.refresh(u) + assert u.cas_allow_login is False + db.session.delete(u) + db.session.commit()