This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
22
services/profiles/alembic/script.py.mako
Normal file
22
services/profiles/alembic/script.py.mako
Normal 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"}
|
||||
@@ -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 ###
|
||||
55
services/profiles/alembic/versions/769f535c9249_init.py
Normal file
55
services/profiles/alembic/versions/769f535c9249_init.py
Normal 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 ###
|
||||
@@ -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
|
||||
|
||||
@@ -8,3 +8,4 @@ pydantic-settings
|
||||
python-dotenv
|
||||
httpx>=0.27
|
||||
pytest
|
||||
PyJWT>=2.8
|
||||
|
||||
31
services/profiles/src/app/api/routes/profiles.py
Normal file
31
services/profiles/src/app/api/routes/profiles.py
Normal 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)
|
||||
59
services/profiles/src/app/core/security.py
Normal file
59
services/profiles/src/app/core/security.py
Normal 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)
|
||||
10
services/profiles/src/app/db/deps.py
Normal file
10
services/profiles/src/app/db/deps.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from .profile import Profile # noqa
|
||||
from .photo import Photo # noqa
|
||||
|
||||
27
services/profiles/src/app/models/photo.py
Normal file
27
services/profiles/src/app/models/photo.py
Normal 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")
|
||||
29
services/profiles/src/app/models/profile.py
Normal file
29
services/profiles/src/app/models/profile.py
Normal 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")
|
||||
26
services/profiles/src/app/repositories/profile_repository.py
Normal file
26
services/profiles/src/app/repositories/profile_repository.py
Normal 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
|
||||
32
services/profiles/src/app/schemas/profile.py
Normal file
32
services/profiles/src/app/schemas/profile.py
Normal 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
|
||||
13
services/profiles/src/app/services/profile_service.py
Normal file
13
services/profiles/src/app/services/profile_service.py
Normal 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)
|
||||
Reference in New Issue
Block a user