diff --git a/flask_app/app/auth/routes.py b/flask_app/app/auth/routes.py index 03b4879..2ccc1e8 100644 --- a/flask_app/app/auth/routes.py +++ b/flask_app/app/auth/routes.py @@ -14,7 +14,7 @@ from flask import ( url_for, ) -from ..config import get_dev_fio_password, is_assignment_feature_enabled, is_dev_ui +from ..config import get_dev_fio_password, is_assignment_feature_enabled, is_dev_ui, is_hr_auth_enabled from ..messages import RU from .decorators import login_required, current_user from .services import AuthError, authenticate_credentials @@ -50,6 +50,7 @@ def login_page(): 'auth/login.html', next=_safe_next('/'), dev_fio_enabled=bool(get_dev_fio_password()), + hr_auth_enabled=is_hr_auth_enabled(), ) @@ -66,6 +67,7 @@ def login_submit(): next=_safe_next('/'), login=login, dev_fio_enabled=bool(get_dev_fio_password()), + hr_auth_enabled=is_hr_auth_enabled(), ), e.status except Exception: log.exception('login_submit failed') @@ -75,6 +77,7 @@ def login_submit(): next=_safe_next('/'), login=login, dev_fio_enabled=bool(get_dev_fio_password()), + hr_auth_enabled=is_hr_auth_enabled(), ), 500 return redirect(_safe_next('/')) diff --git a/flask_app/app/auth/services.py b/flask_app/app/auth/services.py index 0981793..a7ee63f 100644 --- a/flask_app/app/auth/services.py +++ b/flask_app/app/auth/services.py @@ -92,6 +92,39 @@ def _normalize_fio(s: str) -> str: return re.sub(r'\s+', ' ', t).strip() +_FIO_STAFF_SQL = text( + """ + SELECT id, fio, web_login + FROM staff_members + WHERE fio IS NOT NULL + AND lower( + regexp_replace( + trim(replace(replace(coalesce(fio, ''), chr(160), ' '), E'\\t', ' ')), + '[[:space:]]+', ' ', 'g' + ) + ) = :needle + """ +) + + +def _staff_rows_by_normalized_fio(hr_conn, fio_raw: str) -> list: + """Строки staff_members с тем же ФИО, что и ввод, после нормализации (регистр не важен).""" + fio_needle = _normalize_fio(fio_raw) + if not fio_needle: + return [] + needle = fio_needle.lower() + return list(hr_conn.execute(_FIO_STAFF_SQL, {'needle': needle}).mappings().all()) + + +def _single_web_login_from_fio(hr_conn, fio_raw: str) -> str | None: + """Если по ФИО найден ровно один сотрудник — его web_login для входа в HR; иначе None.""" + rows = _staff_rows_by_normalized_fio(hr_conn, fio_raw) + if len(rows) != 1: + return None + wl = (rows[0]['web_login'] or '').strip() + return wl or None + + def authenticate_credentials(login: str, password: str) -> AuthUser: login = (login or '').strip() password = password or '' @@ -122,25 +155,8 @@ def _authenticate_dev_fio(fio_raw: str) -> AuthUser: if not fio_needle: raise AuthError(401, RU['invalidCredentials']) - needle = fio_needle.lower() - with hr_eng.connect() as hr_conn: - rows = hr_conn.execute( - text( - """ - SELECT id, fio, web_login - FROM staff_members - WHERE fio IS NOT NULL - AND lower( - regexp_replace( - trim(replace(replace(coalesce(fio, ''), chr(160), ' '), E'\\t', ' ')), - '[[:space:]]+', ' ', 'g' - ) - ) = :needle - """ - ), - {'needle': needle}, - ).mappings().all() + rows = _staff_rows_by_normalized_fio(hr_conn, fio_raw) if len(rows) != 1: raise AuthError(401, RU['invalidCredentials']) s = rows[0] @@ -208,41 +224,51 @@ def _authenticate_via_hr(login: str, password: str) -> AuthUser: if hr_eng is None: raise AuthError(500, RU['hrDatabaseUrlMissing']) + user_row_sql = text( + 'SELECT id, username, password_hash, role FROM users ' + 'WHERE LOWER(TRIM(username)) = LOWER(TRIM(:login))' + ) + with hr_eng.connect() as hr_conn: - u = hr_conn.execute( - text( - 'SELECT id, username, password_hash, role FROM users ' - 'WHERE LOWER(TRIM(username)) = LOWER(TRIM(:login))' - ), - {'login': login}, - ).mappings().first() + u = hr_conn.execute(user_row_sql, {'login': login}).mappings().first() + if not u: + wl = _single_web_login_from_fio(hr_conn, login) + if wl: + u = hr_conn.execute(user_row_sql, {'login': wl}).mappings().first() + if not u or not u['password_hash']: raise AuthError(401, RU['invalidCredentials']) if not _verify_password(password, u['password_hash']): raise AuthError(401, RU['invalidCredentials']) + hr_username = (u['username'] or '').strip() + if not hr_username: + raise AuthError(401, RU['invalidCredentials']) + s = hr_conn.execute( text( 'SELECT id, fio FROM staff_members ' "WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM(:login))" ), - {'login': login}, + {'login': hr_username}, ).mappings().first() if not s: raise AuthError(403, RU['noStaffForLogin']) staff_id = int(s['id']) - fio = s['fio'] or login + fio = s['fio'] or hr_username app_role = map_hr_role_to_app(u['role']) + canonical_login = hr_username - # UPSERT через ORM: ищем по staff_id, затем по login, затем создаём + # UPSERT через ORM: ищем по staff_id, затем по логину HR, затем по вводу (ФИО/старый логин) session = get_session() user = session.query(User).filter(User.staff_id == staff_id).first() if not user: - # при первом входе staff_id ещё не проставлен — ищем по login + user = session.query(User).filter(User.login == canonical_login).first() + if not user: user = session.query(User).filter(User.login == login).first() if user: - user.login = login + user.login = canonical_login user.full_name = fio user.role = app_role user.password_hash = HR_MANAGED_PASSWORD_PLACEHOLDER @@ -250,7 +276,7 @@ def _authenticate_via_hr(login: str, password: str) -> AuthUser: user.is_active = True else: user = User( - login=login, + login=canonical_login, password_hash=HR_MANAGED_PASSWORD_PLACEHOLDER, full_name=fio, role=app_role, diff --git a/flask_app/app/services/test_access.py b/flask_app/app/services/test_access.py index 241b5c8..74362ed 100644 --- a/flask_app/app/services/test_access.py +++ b/flask_app/app/services/test_access.py @@ -38,6 +38,10 @@ def user_has_test_access(user_id: str, test_id: str) -> AccessResult: if is_test_author(test.created_by, uid): return AccessResult(ok=True, is_author=True, not_found=False) + # Глобальная политика доступа: любой активный тест доступен всем сотрудникам. + if bool(test.is_active): + return AccessResult(ok=True, is_author=False, not_found=False) + assigned = session.query( exists().where( TestAssignmentTarget.target_type == 'user', diff --git a/flask_app/app/templates/auth/login.html b/flask_app/app/templates/auth/login.html index b54beee..3dbca52 100644 --- a/flask_app/app/templates/auth/login.html +++ b/flask_app/app/templates/auth/login.html @@ -2,6 +2,7 @@ {% block title %}Вход — Тестирование{% endblock %} {% block content %} +{% set fio_as_login = dev_fio_enabled or hr_auth_enabled %} {% if ui_variant == 'legacy' %}
Введите ФИО из кадровой системы и общий dev-пароль — или обычный логин/пароль.
+ {% elif hr_auth_enabled %} ++ Можно ввести логин из HR или ФИО (как в кадровой системе), если совпадение одно, и пароль учётной записи HR. +
{% endif %}