Учебный проект — упрощённый клон Twitter.
Реализован бэкенд на FastAPI + SQLAlchemy (async), база данных PostgreSQL.
Фронтенд (Vue.js, собранный dist) монтируется как статические файлы через FastAPI.
Инфраструктура Docker Compose · Alembic · Pytest · Ruff/Black/Mypy.
- Постинг твитов с текстом и медиа
- Лента твитов (сортировка: лайки ↓, дата ↓)
- Подписки и отписки между пользователями
- Лайки и снятие лайков
- Загрузка медиа (PNG, JPG)
- Просмотр профиля пользователя и «о себе» (
/api/users/me)
- models/ — ORM-модели (User, Tweet, Media, Like, Follow)
- schemas/ — Pydantic-схемы (DTO-объекты для API)
- services/ — бизнес-логика (CRUD, валидации)
- routers/ — REST API маршруты (FastAPI)
- exceptions.py — собственные ошибки (EntityNotFound, DomainValidation и др.)
- dependencies.py — зависимости FastAPI (например, current_user)
- User — пользователь (username/api_key)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False)
api_key: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False)
created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now())
tweets = relationship("Tweet", back_populates="author", cascade="all, delete-orphan")
# отношения подписок реализованы через модель Follow
followers = relationship(
"Follow", foreign_keys="Follow.followee_id",
back_populates="followee", cascade="all, delete-orphan"
)
following = relationship(
"Follow", foreign_keys="Follow.follower_id",
back_populates="follower", cascade="all, delete-orphan"
)
likes = relationship("Like", back_populates="user", cascade="all, delete-orphan")В API атрибут пользователя называется name, в БД — username. Отображение сделано через Pydantic-алиасы (см. схемы).
- Tweet — твит, текст ≤ 280 символов, может содержать медиа
class Tweet(Base):
__tablename__ = "tweets"
id = mapped_column(Integer, primary_key=True)
author_id = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
content = mapped_column(String(280), nullable=False)
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
author = relationship("User", back_populates="tweets")
attachments = relationship("Media", secondary="tweet_media", back_populates="tweets", lazy="selectin")
likes = relationship("Like", back_populates="tweet", cascade="all, delete-orphan")- Media — прикреплённые файлы (PNG/JPG), хранятся как
path
class Media(Base):
__tablename__ = "medias"
id = mapped_column(Integer, primary_key=True)
path = mapped_column(String, nullable=False) # относительный путь, например "media/abc.png"
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
tweets = relationship("Tweet", secondary="tweet_media", back_populates="attachments")
TweetMedia (M2M)
tweet_media = Table(
"tweet_media", Base.metadata,
Column("tweet_id", ForeignKey("tweets.id", ondelete="CASCADE"), primary_key=True),
Column("media_id", ForeignKey("medias.id", ondelete="CASCADE"), primary_key=True),
UniqueConstraint("tweet_id", "media_id", name="uq_tweet_media")
)- Like — связь User ↔ Tweet
class Like(Base):
__tablename__ = "likes"
id = mapped_column(Integer, primary_key=True)
user_id = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
tweet_id = mapped_column(ForeignKey("tweets.id", ondelete="CASCADE"), index=True)
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (UniqueConstraint("user_id", "tweet_id", name="uq_user_tweet"),)
user = relationship("User", back_populates="likes")
tweet = relationship("Tweet", back_populates="likes")- Follow — подписки между User ↔ User
class Follow(Base):
__tablename__ = "follows"
id = mapped_column(Integer, primary_key=True)
follower_id = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
followee_id = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (UniqueConstraint("follower_id", "followee_id", name="uq_follow_pair"),)
follower = relationship("User", foreign_keys=[follower_id], back_populates="following")
followee = relationship("User", foreign_keys=[followee_id], back_populates="followers")Общие
class SimpleResult(BaseModel):
result: bool = True
class ErrorResponse(BaseModel):
result: bool = False
error_type: str
error_message: strПользователи
class UserPublic(BaseModel):
id: int
name: str = Field(alias="username", serialization_alias="name")
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
class UserProfile(UserPublic):
followers: list[UserPublic]
following: list[UserPublic]
class UserProfileResponse(BaseModel):
result: bool = True
user: UserProfileТвиты и лайки
class LikeUser(BaseModel):
user_id: int
name: str
class TweetCreate(BaseModel):
tweet_data: str = Field(min_length=1, max_length=280)
tweet_media_ids: list[int] | None = None
class TweetOut(BaseModel):
id: int
content: str
attachments: list[str]
author: UserPublic # строго {id, name}
likes: list[LikeUser] = Field(default_factory=list)
class PostTweetResponse(BaseModel):
result: bool = True
tweet_id: int
class TweetsResponse(BaseModel):
result: bool = True
tweets: list[TweetOut]Медиа
class MediaUploadResponse(BaseModel):
result: bool = True
media_id: intservices/users.py:
- get_public_profile(session, user_id) — профиль с followers/following.
- follow(session, follower_id, followee_id) — запрет самоподписки, уникальность пары; duplicate → AlreadyExists.
- unfollow(session, follower_id, followee_id) — идемпотентно (всегда OK).
services/tweets.py:
- create_tweet(session, author_id, content, media_ids) — валидация, привязка медиа, возврат DTO.
- delete_tweet(session, author_id, tweet_id) — только автор может удалить.
- list_tweets(session, author_id?) — список твитов (опционально автора).
- list_feed_for_user(session, viewer_id) — feed = мои + тех, на кого я подписан; сортировка likes ↓, created_at ↓.
services/likes.py:
- like_tweet(session, user_id, tweet_id) — уникальность (двойной лайк → AlreadyExists).
- unlike_tweet(session, user_id, tweet_id) — идемпотентно.
services/medias.py:
- upload_media(session, file: UploadFile) — одиночная загрузка, MIME-whitelist, запись на диск, возврат media_id.
POST /api/tweets— создать твитDELETE /api/tweets/{id}— удалить твитGET /api/tweets— лента твитовPOST /api/medias— загрузить медиаPOST /api/tweets/{id}/likes— поставить лайкDELETE /api/tweets/{id}/likes— убрать лайкPOST /api/users/{id}/follow— подписатьсяDELETE /api/users/{id}/follow— отписатьсяGET /api/users/me— текущий пользовательGET /api/users/{id}— профиль пользователя
- Исключения → JSON-ошибки
- EntityNotFound → 404: {"result": false, "error_type":"EntityNotFound", "error_message": "..."}
- ForbiddenAction → 403
- AlreadyExists → 409
- DomainValidation → 400
- get_current_user (dependency)
- Читает заголовок api-key. Если отсутствует — 401 "Missing api-key". Если невалиден — 401 "Invalid api-key".
- Возвращает User для сервисов.
- На этапе отладки роутов использовалась SQLite (in-memory / file) для упрощения тестирования.
- Финальная версия использует PostgreSQL с миграциями Alembic.
.env— для production (PostgreSQL, Docker Compose)..env.local— для локальной разработки и отладки (например, SQLite).- Файлы с секретами и локальными настройками в репозиторий не добавляются (
.gitignore).
Миграции базы данных управляются с помощью Alembic.
alembic.ini— общие настройки (путь к миграциям, логирование).app/db/migrations/— каталог с версиями миграций.- В
env.pyиспользуетсяsettings.DATABASE_URLиз.envили.env.local.
# создать новую ревизию (после изменения моделей)
alembic revision --autogenerate -m "описание изменений"
# применить все миграции
alembic upgrade head
# откатить последнюю миграцию
alembic downgrade -1- Установить зависимости для разработки (тесты + линтеры):
pip install -r requirements-dev.txt- Поднять PostgreSQL через docker (если локально нет базы):
docker-compose up db -d- Применить миграции:
alembic upgrade headПосле этого API доступно по адресу: http://localhost:8000
- Собрать и запустить проект (backend + PostgreSQL + фронт):
docker-compose up --build -d- После запуска доступны:
API по адресу: http://localhost:8000
Swagger: http://localhost:8000/docs
Фронтенд (Vue dist): http://localhost:8000
Сервисы:
app— FastAPI-приложениеdb— PostgreSQL (volume для данных)alembic— миграции
Для работы фронтенда и проверки API нужен тестовый пользователь с API-ключом test.
В проекте есть скрипт seed.py, который добавляет такого пользователя в базу:
python seed.py- База данных сохраняется в volume
pg_data - Запуск фронтенда — монтируется из
dist/ - Переменные окружения берутся из
.env
Фреймворк: pytest + pytest-asyncio.
Все тесты лежат в app/tests/.
- users:
me, профиль, follow/unfollow (успех, ошибки, идемпотентность) - tweets: создание (с/без медиа), удаление, валидации, лента, сортировка
- likes: постановка лайка, снятие, дубликаты, отображение в ленте
- medias: загрузка PNG, привязка к твиту, неверный формат → ошибка
Запуск:
pytest -vИспользуются:
- ruff — проверка стиля (PEP8 + isort)
- black — автоформатирование
- mypy — статическая типизация
Запуск:
ruff check .
black .
mypy .- chore: init backend structure
- chore: add root init.py and dist static frontend
- feat: add User and Tweet models
- feat: add Media, Like and Follow models
- chore: configure SQLAlchemy with PostgreSQL
- chore: setup Alembic migrations (init schema)
- feat: add CRUD services for tweets and users
- feat: add Tweet and User routers (endpoints)
- feat: add Media upload endpoint
- feat: implement likes and follows endpoints
- docs: update README with API usage examples
- test: add unit tests for Tweet API
- test: add unit tests for User API
- chore: add pytest configuration
- build: add docker-compose for postgres and app
- chore: add wemake-python-styleguide lint config
- refactor: clean up routers and services
- docs: final update of README with deploy instructions
Для работы фронтенда нужен пользователь с API-ключом "test":
{ "username": "test", "api_key": "test" }(создать вручную в базе или через seed).
Для работы фронтенда и проверки API нужен тестовый пользователь с API-ключом test.
В проекте есть скрипт seed.py, который добавляет такого пользователя в базу:
python seed.py- Добавление нового твита
- Удаление твита
- Подписка / отписка
- Лайки
- Лента твитов
- Медиа
- Профиль / о себе
- Docker Compose
- Сохранение данных в volume
- Swagger
- Unit-тесты
- Линтеры
- README
В базе данных и модели SQLAlchemy используется поле username — это техническое имя,
которое удобно для хранения и однозначной идентификации пользователей.
В Pydantic-схемах и ответах API это поле транслируется в name, чтобы интерфейс был
более «человечным» и соответствовал формату ТЗ.
Это реализовано через алиасы (ConfigDict(from_attributes=True)), поэтому внутри кода
используется username, а наружу всегда возвращается name.
Таким образом:
- В БД и моделях:
username - В API-ответах:
name
Это осознанное решение для разделения внутреннего уровня (ORM) и внешнего контракта (API).