diff --git a/alembic/versions/006_name_manually_edited.py b/alembic/versions/006_name_manually_edited.py new file mode 100644 index 0000000..62bb076 --- /dev/null +++ b/alembic/versions/006_name_manually_edited.py @@ -0,0 +1,35 @@ +"""Add name_manually_edited to users + +Revision ID: 006 +Revises: 005 +Create Date: 2025-02-19 + +When True, full_name/first_name/last_name are not overwritten by get_or_create_user +(Telegram sync). Set manually or via update_user_display_name when editing name in DB/API. +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "006" +down_revision: Union[str, None] = "005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "name_manually_edited", + sa.Boolean(), + nullable=False, + server_default=sa.text("0"), + ), + ) + + +def downgrade() -> None: + op.drop_column("users", "name_manually_edited") diff --git a/duty_teller/db/__init__.py b/duty_teller/db/__init__.py index 60127ce..7e3a2f2 100644 --- a/duty_teller/db/__init__.py +++ b/duty_teller/db/__init__.py @@ -21,6 +21,7 @@ from duty_teller.db.repository import ( get_duties, insert_duty, set_user_phone, + update_user_display_name, ) __all__ = [ @@ -42,6 +43,7 @@ __all__ = [ "get_duties", "insert_duty", "set_user_phone", + "update_user_display_name", "init_db", ] diff --git a/duty_teller/db/models.py b/duty_teller/db/models.py index 51bccca..6265e1e 100644 --- a/duty_teller/db/models.py +++ b/duty_teller/db/models.py @@ -1,6 +1,6 @@ """SQLAlchemy ORM models for users and duties.""" -from sqlalchemy import ForeignKey, Integer, BigInteger, Text +from sqlalchemy import Boolean, ForeignKey, Integer, BigInteger, Text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -22,6 +22,9 @@ class User(Base): first_name: Mapped[str | None] = mapped_column(Text, nullable=True) last_name: Mapped[str | None] = mapped_column(Text, nullable=True) phone: Mapped[str | None] = mapped_column(Text, nullable=True) + name_manually_edited: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default="0", default=False + ) duties: Mapped[list["Duty"]] = relationship("Duty", back_populates="user") diff --git a/duty_teller/db/repository.py b/duty_teller/db/repository.py index 4196404..9ff2cb5 100644 --- a/duty_teller/db/repository.py +++ b/duty_teller/db/repository.py @@ -23,12 +23,18 @@ def get_or_create_user( first_name: str | None = None, last_name: str | None = None, ) -> User: + """ + Get or create user by telegram_user_id. On create, name comes from Telegram. + On update: username is always synced; full_name/first_name/last_name are only + updated if name_manually_edited is False (otherwise keep existing display name). + """ user = get_user_by_telegram_id(session, telegram_user_id) if user: - user.full_name = full_name user.username = username - user.first_name = first_name - user.last_name = last_name + if not user.name_manually_edited: + user.full_name = full_name + user.first_name = first_name + user.last_name = last_name session.commit() session.refresh(user) return user @@ -38,6 +44,7 @@ def get_or_create_user( username=username, first_name=first_name, last_name=last_name, + name_manually_edited=False, ) session.add(user) session.commit() @@ -46,7 +53,10 @@ def get_or_create_user( def get_or_create_user_by_full_name(session: Session, full_name: str) -> User: - """Find user by exact full_name or create one with telegram_user_id=None (for duty-schedule import).""" + """ + Find user by exact full_name or create one with telegram_user_id=None (for duty-schedule import). + New users get name_manually_edited=True since the name comes from import, not Telegram. + """ user = session.query(User).filter(User.full_name == full_name).first() if user: return user @@ -56,6 +66,7 @@ def get_or_create_user_by_full_name(session: Session, full_name: str) -> User: username=None, first_name=None, last_name=None, + name_manually_edited=True, ) session.add(user) session.commit() @@ -63,6 +74,30 @@ def get_or_create_user_by_full_name(session: Session, full_name: str) -> User: return user +def update_user_display_name( + session: Session, + telegram_user_id: int, + full_name: str, + first_name: str | None = None, + last_name: str | None = None, +) -> User | None: + """ + Update display name for user by telegram_user_id and set name_manually_edited=True. + Use from API or admin when name is changed manually; subsequent get_or_create_user + will not overwrite these fields. Returns User or None if not found. + """ + user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first() + if not user: + return None + user.full_name = full_name + user.first_name = first_name + user.last_name = last_name + user.name_manually_edited = True + session.commit() + session.refresh(user) + return user + + def delete_duties_in_range( session: Session, user_id: int, diff --git a/tests/test_repository_duty_range.py b/tests/test_repository_duty_range.py index 2090328..c655997 100644 --- a/tests/test_repository_duty_range.py +++ b/tests/test_repository_duty_range.py @@ -1,4 +1,4 @@ -"""Tests for delete_duties_in_range and get_or_create_user_by_full_name.""" +"""Tests for delete_duties_in_range, get_or_create_user_by_full_name, name_manually_edited.""" import pytest from sqlalchemy import create_engine @@ -7,9 +7,11 @@ from sqlalchemy.orm import sessionmaker from duty_teller.db.models import Base, User from duty_teller.db.repository import ( delete_duties_in_range, + get_or_create_user, get_or_create_user_by_full_name, get_duties, insert_duty, + update_user_display_name, ) @@ -47,6 +49,7 @@ def test_get_or_create_user_by_full_name_creates(session): assert u.id is not None assert u.full_name == "Новый Пользователь" assert u.telegram_user_id is None + assert u.name_manually_edited is True def test_get_or_create_user_by_full_name_returns_existing(session, user_a): @@ -107,3 +110,88 @@ def test_get_duties_includes_duty_starting_on_last_day_of_range(session, user_a) assert len(rows) == 1 assert rows[0][0].start_at == "2026-01-31T09:00:00Z" assert rows[0][1] == "User A" + + +def test_get_or_create_user_overwrites_name_when_flag_false(session): + """When name_manually_edited is False, second get_or_create_user overwrites name.""" + u1 = get_or_create_user( + session, + telegram_user_id=100, + full_name="First Name", + username="user1", + first_name="First", + last_name="Name", + ) + assert u1.full_name == "First Name" + assert u1.name_manually_edited is False + u2 = get_or_create_user( + session, + telegram_user_id=100, + full_name="Second Name", + username="user2", + first_name="Second", + last_name="Name", + ) + assert u2.id == u1.id + assert u2.full_name == "Second Name" + assert u2.first_name == "Second" + assert u2.last_name == "Name" + assert u2.username == "user2" + + +def test_get_or_create_user_keeps_name_when_flag_true_updates_username(session): + """When name_manually_edited is True, get_or_create_user keeps name but updates username.""" + u1 = get_or_create_user( + session, + telegram_user_id=200, + full_name="Custom Name", + username="old_username", + first_name="Custom", + last_name="Name", + ) + u1.name_manually_edited = True + session.commit() + session.refresh(u1) + u2 = get_or_create_user( + session, + telegram_user_id=200, + full_name="Telegram Name", + username="new_username", + first_name="Telegram", + last_name="Name", + ) + assert u2.id == u1.id + assert u2.full_name == "Custom Name" + assert u2.first_name == "Custom" + assert u2.last_name == "Name" + assert u2.username == "new_username" + + +def test_update_user_display_name_sets_flag_then_get_or_create_user_keeps_name(session): + """update_user_display_name sets name and flag; get_or_create_user then does not overwrite name.""" + get_or_create_user( + session, + telegram_user_id=300, + full_name="Original", + username="u3", + first_name="Original", + last_name=None, + ) + updated = update_user_display_name( + session, 300, "Manual Name", first_name="Manual", last_name="Name" + ) + assert updated is not None + assert updated.full_name == "Manual Name" + assert updated.name_manually_edited is True + u_after_sync = get_or_create_user( + session, + telegram_user_id=300, + full_name="From Telegram", + username="new_u3", + first_name="From", + last_name="Telegram", + ) + assert u_after_sync.full_name == "Manual Name" + assert u_after_sync.first_name == "Manual" + assert u_after_sync.last_name == "Name" + assert u_after_sync.username == "new_u3"