/
Ru5Kasper
/
Trackly
Обзор
Документация
Войти
/
Ru5Kasper
/
Trackly
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
backend/app/routers/sync.py
395 строк
15 KB
DORANDKOR
feat(backend): offline first
19 май 2026, 12:21
19 май 2026, 12:21
d70f9df
Код
Авторство
О чём код?
from datetime import UTC, datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app import models from app.auth import oauth2_scheme from app.database import get_db from app.routers.users import get_current_user_id from app.schemas import SyncIdMapping, SyncRequest, SyncResponse router = APIRouter() def _to_utc(ts: datetime) -> datetime: if ts.tzinfo is None: return ts.replace(tzinfo=UTC) return ts.astimezone(UTC) async def _get_owned_habit_by_id( habit_id: int, user_id: int, db: AsyncSession ) -> models.Habit | None: result = await db.execute( select(models.Habit).where( models.Habit.id == habit_id, models.Habit.user_id == user_id, ) ) return result.scalars().first() async def _get_owned_habit_by_client_ref( client_ref: str, user_id: int, db: AsyncSession ) -> models.Habit | None: result = await db.execute( select(models.Habit).where( models.Habit.user_id == user_id, models.Habit.client_ref == client_ref, ) ) return result.scalars().first() async def _get_owned_log_by_id( log_id: int, user_id: int, db: AsyncSession ) -> models.HabitLog | None: result = await db.execute( select(models.HabitLog) .join(models.Habit, models.Habit.id == models.HabitLog.habit_id) .where( models.HabitLog.id == log_id, models.Habit.user_id == user_id, ) ) return result.scalars().first() async def _get_log_by_habit_and_client_ref( habit_id: int, client_ref: str, db: AsyncSession ) -> models.HabitLog | None: result = await db.execute( select(models.HabitLog).where( models.HabitLog.habit_id == habit_id, models.HabitLog.client_ref == client_ref, ) ) return result.scalars().first() @router.post("/sync", response_model=SyncResponse) async def sync_data( payload: SyncRequest, token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[AsyncSession, Depends(get_db)], ): user_id = get_current_user_id(token) last_synced_at = _to_utc(payload.last_synced_at) if payload.last_synced_at else None user_result = await db.execute(select(models.User).where(models.User.id == user_id)) user = user_result.scalars().first() if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", headers={"WWW-Authenticate": "Bearer"}, ) habit_cache_by_id: dict[int, models.Habit] = {} habit_cache_by_client_ref: dict[str, models.Habit] = {} habit_id_mappings: dict[str, int] = {} habit_log_id_mappings: dict[str, int] = {} habits_touched_by_logs: set[int] = set() latest_log_update_by_habit: dict[int, datetime] = {} if payload.user and _to_utc(payload.user.updated_at) > user.updated_at: user_updated_at = _to_utc(payload.user.updated_at) incoming_user_fields = payload.user.model_fields_set if "name" in incoming_user_fields and payload.user.name is not None: user.name = payload.user.name if "email" in incoming_user_fields and payload.user.email is not None: user.email = payload.user.email.lower() if "theme" in incoming_user_fields and payload.user.theme is not None: user.theme = payload.user.theme if ( "disable_notifications" in incoming_user_fields and payload.user.disable_notifications is not None ): user.disable_notifications = payload.user.disable_notifications if "avatar_url" in incoming_user_fields: user.avatar_url = payload.user.avatar_url user.updated_at = user_updated_at for incoming_habit in payload.habits: incoming_habit_updated_at = _to_utc(incoming_habit.updated_at) incoming_habit_created_at = ( _to_utc(incoming_habit.created_at) if incoming_habit.created_at else None ) incoming_habit_deleted_at = ( _to_utc(incoming_habit.deleted_at) if incoming_habit.deleted_at else None ) habit: models.Habit | None = None if incoming_habit.id is not None: habit = await _get_owned_habit_by_id( habit_id=incoming_habit.id, user_id=user_id, db=db, ) if habit is None and incoming_habit.client_ref is not None: habit = await _get_owned_habit_by_client_ref( client_ref=incoming_habit.client_ref, user_id=user_id, db=db, ) if habit is None: if incoming_habit_deleted_at is not None: continue missing_fields = [ field_name for field_name, field_value in { "title": incoming_habit.title, "description": incoming_habit.description, "color": incoming_habit.color, "target_count": incoming_habit.target_count, "is_active": incoming_habit.is_active, }.items() if field_value is None ] if missing_fields: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=( "Missing required habit fields for new record: " f"{', '.join(missing_fields)}" ), ) created_at = incoming_habit_created_at or incoming_habit_updated_at updated_at = max(incoming_habit_updated_at, created_at) habit = models.Habit( user_id=user_id, client_ref=incoming_habit.client_ref, title=incoming_habit.title, description=incoming_habit.description, color=incoming_habit.color, frequency=incoming_habit.frequency or 0, target_count=incoming_habit.target_count, is_active=incoming_habit.is_active, schedule=( incoming_habit.schedule.model_dump() if incoming_habit.schedule is not None else None ), created_at=created_at, updated_at=updated_at, deleted_at=None, ) db.add(habit) await db.flush() elif incoming_habit_updated_at > habit.updated_at: incoming_fields = incoming_habit.model_fields_set if "client_ref" in incoming_fields and incoming_habit.client_ref is not None: habit.client_ref = incoming_habit.client_ref if "title" in incoming_fields and incoming_habit.title is not None: habit.title = incoming_habit.title if "description" in incoming_fields and incoming_habit.description is not None: habit.description = incoming_habit.description if "color" in incoming_fields and incoming_habit.color is not None: habit.color = incoming_habit.color if "frequency" in incoming_fields and incoming_habit.frequency is not None: habit.frequency = incoming_habit.frequency if "target_count" in incoming_fields and incoming_habit.target_count is not None: habit.target_count = incoming_habit.target_count if "is_active" in incoming_fields and incoming_habit.is_active is not None: habit.is_active = incoming_habit.is_active if "schedule" in incoming_fields: habit.schedule = ( incoming_habit.schedule.model_dump() if incoming_habit.schedule is not None else None ) if "deleted_at" in incoming_fields: habit.deleted_at = incoming_habit_deleted_at if incoming_habit_deleted_at is not None: habit.is_active = False habit.updated_at = incoming_habit_updated_at if incoming_habit.client_ref is not None and habit.client_ref is None: habit.client_ref = incoming_habit.client_ref habit_cache_by_id[habit.id] = habit if habit.client_ref: habit_cache_by_client_ref[habit.client_ref] = habit if incoming_habit.client_ref: habit_id_mappings[incoming_habit.client_ref] = habit.id for incoming_log in payload.habit_logs: incoming_log_updated_at = _to_utc(incoming_log.updated_at) incoming_log_completed_at = ( _to_utc(incoming_log.completed_at) if incoming_log.completed_at else None ) incoming_log_deleted_at = ( _to_utc(incoming_log.deleted_at) if incoming_log.deleted_at else None ) habit: models.Habit | None = None if incoming_log.habit_id is not None: habit = habit_cache_by_id.get(incoming_log.habit_id) if habit is None: habit = await _get_owned_habit_by_id( habit_id=incoming_log.habit_id, user_id=user_id, db=db, ) if habit is None and incoming_log.habit_client_ref is not None: mapped_id = habit_id_mappings.get(incoming_log.habit_client_ref) if mapped_id is not None: habit = habit_cache_by_id.get(mapped_id) if habit is None: habit = habit_cache_by_client_ref.get(incoming_log.habit_client_ref) if habit is None: habit = await _get_owned_habit_by_client_ref( client_ref=incoming_log.habit_client_ref, user_id=user_id, db=db, ) if habit is None: continue habit_cache_by_id[habit.id] = habit if habit.client_ref: habit_cache_by_client_ref[habit.client_ref] = habit log: models.HabitLog | None = None if incoming_log.id is not None: log = await _get_owned_log_by_id( log_id=incoming_log.id, user_id=user_id, db=db, ) if log is None and incoming_log.client_ref is not None: log = await _get_log_by_habit_and_client_ref( habit_id=habit.id, client_ref=incoming_log.client_ref, db=db, ) if log is None: if incoming_log_deleted_at is not None: continue completed_at = incoming_log_completed_at or incoming_log_updated_at log_updated_at = max(incoming_log_updated_at, completed_at) log = models.HabitLog( habit_id=habit.id, client_ref=incoming_log.client_ref, completed_at=completed_at, updated_at=log_updated_at, deleted_at=None, ) db.add(log) await db.flush() elif incoming_log_updated_at > log.updated_at: incoming_fields = incoming_log.model_fields_set if "completed_at" in incoming_fields and incoming_log_completed_at is not None: log.completed_at = incoming_log_completed_at if "deleted_at" in incoming_fields: log.deleted_at = incoming_log_deleted_at if "client_ref" in incoming_fields and incoming_log.client_ref is not None: log.client_ref = incoming_log.client_ref log.updated_at = incoming_log_updated_at if incoming_log.client_ref is not None and log.client_ref is None: log.client_ref = incoming_log.client_ref if incoming_log.client_ref: habit_log_id_mappings[incoming_log.client_ref] = log.id habits_touched_by_logs.add(habit.id) current_latest = latest_log_update_by_habit.get(habit.id) if current_latest is None or log.updated_at > current_latest: latest_log_update_by_habit[habit.id] = log.updated_at if habits_touched_by_logs: log_counts_result = await db.execute( select( models.HabitLog.habit_id, func.count(models.HabitLog.id), ) .where( models.HabitLog.habit_id.in_(habits_touched_by_logs), models.HabitLog.deleted_at.is_(None), ) .group_by(models.HabitLog.habit_id) ) log_counts_by_habit = { habit_id: count for habit_id, count in log_counts_result.all() } for habit_id in habits_touched_by_logs: habit = habit_cache_by_id.get(habit_id) if habit is None: habit = await _get_owned_habit_by_id(habit_id=habit_id, user_id=user_id, db=db) if habit is None: continue habit_cache_by_id[habit_id] = habit if habit.client_ref: habit_cache_by_client_ref[habit.client_ref] = habit habit.frequency = int(log_counts_by_habit.get(habit_id, 0)) latest_log_update = latest_log_update_by_habit.get(habit_id) if latest_log_update is not None and latest_log_update > habit.updated_at: habit.updated_at = latest_log_update if habit.deleted_at is not None or habit.frequency >= habit.target_count: habit.is_active = False await db.commit() habits_query = select(models.Habit).where(models.Habit.user_id == user_id) logs_query = ( select(models.HabitLog) .join(models.Habit, models.Habit.id == models.HabitLog.habit_id) .where(models.Habit.user_id == user_id) ) if last_synced_at is not None: habits_query = habits_query.where(models.Habit.updated_at > last_synced_at) logs_query = logs_query.where(models.HabitLog.updated_at > last_synced_at) habits_query = habits_query.order_by(models.Habit.updated_at.asc(), models.Habit.id.asc()) logs_query = logs_query.order_by( models.HabitLog.updated_at.asc(), models.HabitLog.id.asc(), ) habits_result = await db.execute(habits_query) logs_result = await db.execute(logs_query) response_user = ( user if last_synced_at is None or user.updated_at > last_synced_at else None ) return SyncResponse( server_time=datetime.now(UTC), user=response_user, habits=habits_result.scalars().all(), habit_logs=logs_result.scalars().all(), habit_mappings=[ SyncIdMapping(client_ref=client_ref, id=entity_id) for client_ref, entity_id in sorted(habit_id_mappings.items()) ], habit_log_mappings=[ SyncIdMapping(client_ref=client_ref, id=entity_id) for client_ref, entity_id in sorted(habit_log_id_mappings.items()) ], )