+ Если у вас возникли вопросы, свяжитесь с вашим менеджером MONT или напишите на RGalyaviev@mont.ru
+
+
+"""
+
+
+def _credentials_table(rows: list[tuple[str, str, bool]]) -> str:
+ """rows: (label, value, monospace) - renders the same light-theme
+ label/value table used across access emails."""
+ trs = []
+ for i, (label, value, mono) in enumerate(rows):
+ border = "border-top:1px solid #e7ebf0;" if i > 0 else ""
+ font = "font-family:monospace;" if mono else ""
+ trs.append(
+ f'
{label}
'
+ f'
{value}
'
+ )
+ return (
+ '
'
+ + "".join(trs) + "
"
+ )
+
+
+def _send_welcome_email(user, password: str) -> str:
+ """Sent once, when an admin manually creates a user on the Users tab -
+ the only way that person learns their login/password, since the admin
+ never sees the plaintext (create_user() generates it)."""
+ body = (
+ f'
'
+ f'Здравствуйте{", " + escape(user.first_name) + "" if user.first_name else ""}! '
+ f'Для вас создан аккаунт на Инфраструктурном полигоне MONT.
'
+ + _credentials_table([
+ ("Адрес портала", f'{PORTAL_URL}', False),
+ ("Логин", escape(user.username), True),
+ ("Пароль", escape(password), True),
+ ])
+ )
+ html = _build_light_email("Доступ к Инфраструктурному полигону MONT", "Для вас создан аккаунт", body, PORTAL_URL)
+ try:
+ _send_email(user.username, "Доступ к Инфраструктурному полигону MONT", html)
+ return "Email отправлен"
+ except Exception as ex:
+ log_event("email_send_error", error=str(ex), channel="welcome")
+ return f"Ошибка email: {ex}"
+
+
+def _send_password_reset_email(user, password: str) -> str:
+ """Sent when an admin resets a user's password from the Users tab -
+ same reasoning as the welcome email: the plaintext only ever exists
+ in this message, never in the admin UI."""
+ body = (
+ f'
'
+ f'Здравствуйте{", " + escape(user.first_name) + "" if user.first_name else ""}! '
+ f'Пароль от вашего аккаунта на полигоне MONT был сброшен администратором.
'
+ + _credentials_table([
+ ("Адрес портала", f'{PORTAL_URL}', False),
+ ("Логин", escape(user.username), True),
+ ("Новый пароль", escape(password), True),
+ ])
+ )
+ html = _build_light_email("Пароль обновлён", "Инфраструктурный полигон MONT", body, PORTAL_URL)
+ try:
+ _send_email(user.username, "Пароль обновлён — полигон MONT", html)
+ return "Email отправлен"
+ except Exception as ex:
+ log_event("email_send_error", error=str(ex), channel="password_reset")
+ return f"Ошибка email: {ex}"
+
+
+def _send_pilot_granted_email(user, service, expires_at) -> str:
+ """Sent once, the moment a pilot's RDP slot is first assigned to this
+ user (not on every later tweak of that same assignment - see the
+ "new grant" check in assign_rdp_slot()). No credentials here: this
+ user already has an account and password, this just tells them a new
+ product showed up."""
+ expiry_row = (
+ f'
Доступ действует до {expires_at.strftime("%d.%m.%Y")}
'
+ if expires_at else ""
+ )
+ body = (
+ f'
'
+ f'Здравствуйте{", " + escape(user.first_name) + "" if user.first_name else ""}! '
+ f'Вам открыт доступ к пилотному проекту {escape(service.name)} на Инфраструктурном полигоне MONT.
'
+ + expiry_row
+ )
+ html = _build_light_email(f"Доступ к пилоту: {escape(service.name)}", "Новый продукт на полигоне", body, PORTAL_URL, "Открыть полигон")
+ try:
+ _send_email(user.username, f"Доступ к пилоту «{service.name}» — полигон MONT", html)
+ return "Email отправлен"
+ except Exception as ex:
+ log_event("email_send_error", error=str(ex), channel="pilot_granted")
+ return f"Ошибка email: {ex}"
+
+
def _tg_api(method: str, payload: dict) -> dict:
import urllib.request as _ur
import json as _j
@@ -2920,6 +3047,7 @@ def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Dep
UserServiceAccess.service_id == service.id,
)
)
+ is_new_grant = access is None
if access:
access.expires_at = expires_at
else:
@@ -2928,7 +3056,13 @@ def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Dep
slot.assigned_user_id = target_user.id
db.commit()
audit(db, "RDP_SLOT_ASSIGN", f"service={service.slug} slot={slot.id} user={target_user.username}", user_id=None)
- return {"ok": True, "assigned_user_id": target_user.id}
+ email_status = None
+ if is_new_grant:
+ # Only on a genuinely new grant - not every time the admin tweaks
+ # the expiry date for someone who already has this pilot, which
+ # also calls this endpoint.
+ email_status = _send_pilot_granted_email(target_user, service, expires_at)
+ return {"ok": True, "assigned_user_id": target_user.id, "email_status": email_status}
@app.post("/api/admin/categories")
@@ -2972,11 +3106,17 @@ def update_web_pool_size(payload: dict, request: Request, _: User = Depends(requ
@app.post("/api/admin/users")
def create_user(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
+ """The password is never typed by the admin - it's generated here and
+ mailed to the user (username is their email, same convention as the
+ self-service approval flow). If the email fails to send, the plaintext
+ comes back in the response as a one-time fallback so the admin isn't
+ locked out of handing it over; the frontend only surfaces it then."""
validate_csrf(request)
expires_at = dt.datetime.fromisoformat(payload["expires_at"])
+ password = _generate_password()
user = User(
username=payload["username"],
- password_hash=hash_password(payload["password"]),
+ password_hash=hash_password(password),
expires_at=expires_at,
active=payload.get("active", True),
is_admin=payload.get("is_admin", False),
@@ -2985,7 +3125,11 @@ def create_user(payload: dict, request: Request, _: User = Depends(require_admin
)
db.add(user)
db.commit()
- return {"id": user.id}
+ email_status = _send_welcome_email(user, password)
+ result = {"id": user.id, "email_status": email_status}
+ if not email_status.startswith("Email отправлен"):
+ result["password"] = password
+ return result
@app.put("/api/admin/users/{user_id}")
@@ -2997,14 +3141,31 @@ def edit_user(user_id: int, payload: dict, request: Request, _: User = Depends(r
for key in ["username", "active", "is_admin", "first_name", "last_name"]:
if key in payload:
setattr(user, key, payload[key])
- if "password" in payload and payload["password"]:
- user.password_hash = hash_password(payload["password"])
if "expires_at" in payload:
user.expires_at = dt.datetime.fromisoformat(payload["expires_at"])
db.commit()
return {"ok": True}
+@app.post("/api/admin/users/{user_id}/reset-password")
+def reset_user_password(user_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
+ """Generates a new password and emails it - the only way a user's
+ password changes now, mirroring create_user(). Same fallback: if the
+ email bounces, the plaintext comes back so the admin can pass it on."""
+ validate_csrf(request)
+ user = db.get(User, user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ password = _generate_password()
+ user.password_hash = hash_password(password)
+ db.commit()
+ email_status = _send_password_reset_email(user, password)
+ result = {"ok": True, "email_status": email_status}
+ if not email_status.startswith("Email отправлен"):
+ result["password"] = password
+ return result
+
+
@app.delete("/api/admin/users/{user_id}")
def delete_user(user_id: int, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
validate_csrf(request)
diff --git a/app/templates/admin.html b/app/templates/admin.html
index 949f81e..3955f09 100644
--- a/app/templates/admin.html
+++ b/app/templates/admin.html
@@ -258,12 +258,12 @@
-
+
@@ -285,11 +285,11 @@
Добавить пользователя
+
Пароль не задаётся вручную — он сгенерируется и уйдёт пользователю на почту (username) сразу после создания.