api development
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-08-08 21:58:36 +09:00
parent d58302c2c8
commit cc87dcc0fa
157 changed files with 14629 additions and 7 deletions

View File

@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR)
from app.db.session import Base # noqa
from app import models # noqa: F401
config = context.config

View File

@@ -0,0 +1,22 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,26 @@
"""add FK photos.profile_id -> profiles.id
Revision ID: 5c69d1403313
Revises: 769f535c9249
Create Date: 2025-08-08 11:43:53.014776+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5c69d1403313'
down_revision = '769f535c9249'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_foreign_key(None, 'photos', 'profiles', ['profile_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'photos', type_='foreignkey')
# ### end Alembic commands ###

View File

@@ -0,0 +1,55 @@
"""init
Revision ID: 769f535c9249
Revises:
Create Date: 2025-08-08 11:20:05.142049+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '769f535c9249'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('photos',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('profile_id', sa.UUID(), nullable=False),
sa.Column('url', sa.String(length=500), nullable=False),
sa.Column('is_main', sa.Boolean(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_photos_profile_id'), 'photos', ['profile_id'], unique=False)
op.create_table('profiles',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('gender', sa.String(length=16), nullable=False),
sa.Column('birthdate', sa.Date(), nullable=True),
sa.Column('city', sa.String(length=120), nullable=True),
sa.Column('bio', sa.Text(), nullable=True),
sa.Column('languages', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('interests', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('preferences', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('verification_status', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_profiles_user_id'), 'profiles', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_profiles_user_id'), table_name='profiles')
op.drop_table('profiles')
op.drop_index(op.f('ix_photos_profile_id'), table_name='photos')
op.drop_table('photos')
# ### end Alembic commands ###

View File

@@ -3,4 +3,4 @@ set -e
# Run migrations (no-op if no revisions yet)
alembic -c alembic.ini upgrade head || true
# Start app
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-level debug

View File

@@ -8,3 +8,4 @@ pydantic-settings
python-dotenv
httpx>=0.27
pytest
PyJWT>=2.8

View File

@@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.deps import get_db
from app.core.security import get_current_user, JwtUser
from app.schemas.profile import ProfileCreate, ProfileOut
from app.repositories.profile_repository import ProfileRepository
from app.services.profile_service import ProfileService
# отключаем авто-редирект /path -> /path/
router = APIRouter(prefix="/v1/profiles", tags=["profiles"], redirect_slashes=False)
@router.get("/me", response_model=ProfileOut)
def get_my_profile(current: JwtUser = Depends(get_current_user),
db: Session = Depends(get_db)):
svc = ProfileService(ProfileRepository(db))
p = svc.get_by_user(current.sub)
if not p:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
return p
@router.post("", response_model=ProfileOut, status_code=status.HTTP_201_CREATED)
def create_my_profile(payload: ProfileCreate,
current: JwtUser = Depends(get_current_user),
db: Session = Depends(get_db)):
svc = ProfileService(ProfileRepository(db))
existing = svc.get_by_user(current.sub)
if existing:
# если хотите строго — верните 409; оставлю 200/201 для удобства e2e
return existing
return svc.create(current.sub, payload)

View File

@@ -0,0 +1,59 @@
import os
from typing import Optional
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
reusable_bearer = HTTPBearer(auto_error=True)
JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
# Возможность включить строгую проверку audience/issuer в будущем
JWT_VERIFY_AUD = os.getenv("JWT_VERIFY_AUD", "0") == "1"
JWT_AUDIENCE: Optional[str] = os.getenv("JWT_AUDIENCE") or None
JWT_VERIFY_ISS = os.getenv("JWT_VERIFY_ISS", "0") == "1"
JWT_ISSUER: Optional[str] = os.getenv("JWT_ISSUER") or None
# Допустимая рассинхронизация часов (сек)
JWT_LEEWAY = int(os.getenv("JWT_LEEWAY", "30"))
class JwtUser(BaseModel):
sub: str
email: Optional[str] = None
role: Optional[str] = None
def decode_token(token: str) -> JwtUser:
options = {
"verify_signature": True,
"verify_exp": True,
"verify_aud": JWT_VERIFY_AUD,
"verify_iss": JWT_VERIFY_ISS,
}
kwargs = {"algorithms": [JWT_ALGORITHM], "options": options, "leeway": JWT_LEEWAY}
if JWT_VERIFY_AUD and JWT_AUDIENCE:
kwargs["audience"] = JWT_AUDIENCE
if JWT_VERIFY_ISS and JWT_ISSUER:
kwargs["issuer"] = JWT_ISSUER
try:
payload = jwt.decode(token, JWT_SECRET, **kwargs)
sub = str(payload.get("sub") or "")
if not sub:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: no sub")
return JwtUser(sub=sub, email=payload.get("email"), role=payload.get("role"))
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except jwt.InvalidAudienceError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid audience")
except jwt.InvalidIssuerError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid issuer")
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(reusable_bearer)) -> JwtUser:
if credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth scheme")
return decode_token(credentials.credentials)

View File

@@ -0,0 +1,10 @@
from typing import Generator
from sqlalchemy.orm import Session
from app.db.session import SessionLocal # должен существовать в проекте
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -1,5 +1,6 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
from .api.routes.profiles import router as profiles_router
app = FastAPI(title="PROFILES Service")
@@ -7,5 +8,5 @@ app = FastAPI(title="PROFILES Service")
def health():
return {"status": "ok", "service": "profiles"}
# v1 API
app.include_router(ping_router, prefix="/v1")
app.include_router(profiles_router)

View File

@@ -0,0 +1,2 @@
from .profile import Profile # noqa
from .photo import Photo # noqa

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.db.session import Base
class Photo(Base):
__tablename__ = "photos"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("profiles.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
url: Mapped[str] = mapped_column(String(500), nullable=False)
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
profile = relationship("Profile", back_populates="photos")

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import String, Date, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from typing import Optional
from app.db.session import Base
class Profile(Base):
__tablename__ = "profiles"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
birthdate: Mapped[date | None] = mapped_column(Date, default=None)
city: Mapped[str | None] = mapped_column(String(120), default=None)
bio: Mapped[str | None] = mapped_column(Text, default=None)
languages: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list)
interests: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list)
preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict)
verification_status: Mapped[str] = mapped_column(String(16), default="unverified")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan")

View File

@@ -0,0 +1,26 @@
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from sqlalchemy import select
from app.models.profile import Profile
from app.schemas.profile import ProfileCreate
class ProfileRepository:
def __init__(self, db: Session):
self.db = db
def get_by_user(self, user_id: UUID) -> Optional[Profile]:
return self.db.execute(select(Profile).where(Profile.user_id == user_id)).scalar_one_or_none()
def create(self, user_id: UUID, data: ProfileCreate) -> Profile:
p = Profile(
user_id=user_id,
gender=data.gender,
city=data.city,
languages=list(data.languages or []),
interests=list(data.interests or []),
)
self.db.add(p)
self.db.commit()
self.db.refresh(p)
return p

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from typing import List
from uuid import UUID
try:
# Pydantic v2
from pydantic import BaseModel, Field, ConfigDict
_V2 = True
except Exception:
# Pydantic v1 fallback
from pydantic import BaseModel, Field
ConfigDict = None
_V2 = False
class ProfileBase(BaseModel):
gender: str
city: str
languages: List[str] = Field(default_factory=list)
interests: List[str] = Field(default_factory=list)
class ProfileCreate(ProfileBase):
pass
class ProfileOut(ProfileBase):
id: UUID
user_id: UUID
if _V2:
model_config = ConfigDict(from_attributes=True)
else:
class Config:
orm_mode = True

View File

@@ -0,0 +1,13 @@
from uuid import UUID
from app.schemas.profile import ProfileCreate
from app.repositories.profile_repository import ProfileRepository
class ProfileService:
def __init__(self, repo: ProfileRepository):
self.repo = repo
def get_by_user(self, user_id: UUID):
return self.repo.get_by_user(user_id)
def create(self, user_id: UUID, data: ProfileCreate):
return self.repo.create(user_id, data)