/
pa1ch
/
FitAssistant
Обзор
Документация
Войти
/
pa1ch
/
FitAssistant
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
app/models/user.py
156 строк
6 KB
Pavel Chugaev
feat(ai): контекст спортсмена во всех AI-запросах + заметка для ИИ в профиле
15 июл 2026, 23:33
15 июл 2026, 23:33
d6d4ba3
Код
Авторство
О чём код?
import enum from datetime import date, datetime, timedelta, timezone from typing import TYPE_CHECKING from sqlalchemy import BigInteger, ForeignKey, Numeric, SmallInteger, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base if TYPE_CHECKING: from app.models.auth_identity import AuthIdentity from app.models.vacation import Vacation from app.models.workout_schedule import WorkoutScheduleSlot class FitnessGoal(enum.StrEnum): LOSE_WEIGHT = "lose_weight" GAIN_MASS = "gain_mass" STAY_FIT = "stay_fit" class Sex(enum.StrEnum): MALE = "male" FEMALE = "female" class ExperienceLevel(enum.StrEnum): BEGINNER = "beginner" INTERMEDIATE = "intermediate" ADVANCED = "advanced" class User(Base): __tablename__ = "users" # Nullable: a user who signed up via Google/Yandex has no Telegram yet. It # stays unique (Postgres allows multiple NULLs) and the bot still needs it # to send reminders, so the UI nudges such users to link Telegram. telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, index=True) name: Mapped[str] email: Mapped[str | None] is_coach: Mapped[bool] = mapped_column(default=False) # Onboarding profile (filled by everyone) fitness_goal: Mapped[FitnessGoal | None] sex: Mapped[Sex | None] birth_year: Mapped[int | None] = mapped_column(SmallInteger) height_cm: Mapped[float | None] = mapped_column(Numeric(5, 1)) experience_level: Mapped[ExperienceLevel | None] onboarding_done: Mapped[bool] = mapped_column(default=False) # Free-form note the athlete writes for the AI coach (injuries, chronic # conditions, personal constraints). Fed into every AI prompt via # services/athlete_context.py — never shown to other users. ai_profile_note: Mapped[str | None] # Timezone offset from UTC in hours (supports half-hour zones, e.g. 5.5 for India) utc_offset_hours: Mapped[float] = mapped_column(Numeric(4, 2), default=0) # Reminder settings (days between reminders, 0 = off) remind_measurement_days: Mapped[int] = mapped_column(default=14) remind_workout_days: Mapped[int] = mapped_column(default=3) # Last reminder sent dates (to avoid daily spam after threshold is hit) last_workout_reminder_sent: Mapped[date | None] = mapped_column(default=None) last_measurement_reminder_sent: Mapped[date | None] = mapped_column(default=None) # Schedule-based workout reminder: at most one per day (the planned slot). last_schedule_reminder_sent: Mapped[date | None] = mapped_column(default=None) # Per-type notification toggles (Telegram push + web bell). Off by default # for new users — they opt in from settings instead of being spammed # from day one; existing users keep whatever they already had. # athlete-facing: coach created a workout for me. notify_coach_workout: Mapped[bool] = mapped_column(default=False) # coach-facing: an athlete completed a workout. notify_athlete_workout: Mapped[bool] = mapped_column(default=False) # coach-facing: a new athlete joined me. notify_new_athlete: Mapped[bool] = mapped_column(default=False) # Linked login methods (telegram, google, yandex, ...). One user ↔ many. identities: Mapped[list["AuthIdentity"]] = relationship( back_populates="user", cascade="all, delete-orphan", lazy="selectin", ) # Weekly training schedule (one slot per weekday). Drives the calendar's # "planned" markers and the schedule editor in settings. schedule_slots: Mapped[list["WorkoutScheduleSlot"]] = relationship( back_populates="user", cascade="all, delete-orphan", lazy="selectin", order_by="WorkoutScheduleSlot.weekday", ) # Vacation periods during which all reminders are paused and the calendar # marks the days off. vacations: Mapped[list["Vacation"]] = relationship( back_populates="user", cascade="all, delete-orphan", lazy="selectin", order_by="Vacation.start_date", ) # coach -> athletes coached_athletes: Mapped[list["CoachAthlete"]] = relationship( foreign_keys="CoachAthlete.coach_id", back_populates="coach", lazy="selectin", ) # athlete -> coaches coaches: Mapped[list["CoachAthlete"]] = relationship( foreign_keys="CoachAthlete.athlete_id", back_populates="athlete", lazy="selectin", ) def now(self) -> datetime: """Current time in the user's local timezone (per utc_offset_hours).""" tz = timezone(timedelta(hours=float(self.utc_offset_hours or 0))) return datetime.now(tz) def today(self) -> date: """Current date in the user's local timezone.""" return self.now().date() def is_on_vacation(self, day: date | None = None) -> bool: """True if `day` (default: today) falls inside any vacation period.""" if day is None: day = self.today() return any(v.start_date <= day <= v.end_date for v in self.vacations) def notification_pref_enabled(self, notification_type: str) -> bool: """Whether this user wants notifications of ``notification_type``. Maps a ``NotificationType`` value to its toggle column; unknown types default to enabled so a new type isn't silently swallowed. """ return { "coach_workout": self.notify_coach_workout, "athlete_workout": self.notify_athlete_workout, "new_athlete": self.notify_new_athlete, }.get(notification_type, True) class CoachAthlete(Base): __tablename__ = "coach_athletes" __table_args__ = (UniqueConstraint("coach_id", "athlete_id"),) coach_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) athlete_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) invite_code: Mapped[str | None] linked_at: Mapped[datetime] = mapped_column(server_default=func.now()) coach: Mapped["User"] = relationship(foreign_keys=[coach_id], back_populates="coached_athletes") athlete: Mapped["User"] = relationship(foreign_keys=[athlete_id], back_populates="coaches")