api endpoints fix and inclusion
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-08-10 15:39:48 +09:00
parent 7ecc556c77
commit b595bcc9bc
65 changed files with 6046 additions and 263 deletions

View File

@@ -7,6 +7,8 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
RUN mkdir -p /app/uploads
COPY src ./src
COPY alembic.ini ./

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env sh
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

View File

@@ -0,0 +1,26 @@
from typing import Optional, List
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from ...db.session import get_db
from ...core.security import require_roles, UserClaims
from ...schemas.user import UserRead
from ...repositories.user_search_repository import UserSearchRepository
router = APIRouter(prefix="/v1/users", tags=["users"])
@router.get("/search", response_model=List[UserRead])
def search_users(q: Optional[str] = None,
role: Optional[str] = Query(None, pattern="^(ADMIN|CLIENT)$"),
is_active: Optional[bool] = None,
email_domain: Optional[str] = None,
created_from: Optional[str] = None,
created_to: Optional[str] = None,
sort_by: Optional[str] = Query(None, pattern="^(full_name|email|created_at|role|is_active)$"),
order: Optional[str] = Query("asc", pattern="^(asc|desc)$"),
offset: int = 0, limit: int = Query(50, le=200),
db: Session = Depends(get_db),
_: UserClaims = Depends(require_roles("ADMIN"))):
repo = UserSearchRepository(db)
return repo.search(q=q, role=role, is_active=is_active, email_domain=email_domain,
created_from=created_from, created_to=created_to,
sort_by=sort_by, order=order, offset=offset, limit=limit)

View File

@@ -0,0 +1,14 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
from .api.routes.auth import router as auth_router
from .api.routes.users import router as users_router
app = FastAPI(title="AUTH Service")
@app.get("/health")
def health():
return {"status": "ok", "service": "auth"}
app.include_router(ping_router, prefix="/v1")
app.include_router(auth_router)
app.include_router(users_router)

View File

@@ -0,0 +1,38 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import or_, func
from ..models.user import User
class UserSearchRepository:
def __init__(self, db: Session):
self.db = db
def search(self, q: Optional[str], role: Optional[str], is_active: Optional[bool],
email_domain: Optional[str], created_from: Optional[str], created_to: Optional[str],
sort_by: Optional[str], order: Optional[str], offset: int, limit: int) -> List[User]:
qry = self.db.query(User)
if q:
like = f"%{q}%"
qry = qry.filter(or_(User.email.ilike(like), User.full_name.ilike(like)))
if role:
qry = qry.filter(User.role == role)
if is_active is not None:
qry = qry.filter(User.is_active == is_active)
if email_domain:
qry = qry.filter(User.email.ilike(f"%@{email_domain}"))
if created_from:
qry = qry.filter(User.created_at >= created_from)
if created_to:
qry = qry.filter(User.created_at <= created_to)
sort_map = {
"full_name": User.full_name,
"email": User.email,
"created_at": User.created_at,
"role": User.role,
"is_active": User.is_active
}
col = sort_map.get(sort_by or "", User.created_at)
if order == "desc":
col = col.desc()
return qry.order_by(col).offset(offset).limit(min(limit, 200)).all()

View File

@@ -7,6 +7,8 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
RUN mkdir -p /app/uploads
COPY src ./src
COPY alembic.ini ./

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env sh
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

View File

@@ -6,6 +6,7 @@ from app.db.session import get_db
from app.core.security import get_current_user, UserClaims
from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead
from app.services.chat_service import ChatService
from fastapi import APIRouter, Depends, HTTPException
router = APIRouter(prefix="/v1", tags=["chat"])
@@ -19,12 +20,11 @@ def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserCl
def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
return ChatService(db).list_rooms_for_user(user.sub)
@router.get("/rooms/{room_id}", response_model=RoomRead)
def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
room = ChatService(db).get_room(room_id)
@router.get("/v1/rooms/{room_id}", response_model=RoomRead)
def get_room(room_id: UUID, db: Session = Depends(get_db), user: UserClaims = Depends(require_auth)):
room = RoomService(db).get(room_id, user.sub)
if not room:
raise HTTPException(status_code=404, detail="Not found")
# NOTE: для простоты опускаем проверку участия (добавьте в проде)
raise HTTPException(status_code=404, detail="Room not found")
return room
@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201)
@@ -36,11 +36,8 @@ def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get
msg = svc.create_message(room_id, user.sub, payload.content)
return msg
@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead])
def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500),
db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ChatService(db)
room = svc.get_room(room_id)
if not room:
@router.get("/v1/rooms/{room_id}/messages", response_model=list[MessageRead])
def list_messages(room_id: UUID, offset: int = 0, limit: int = 100, db: Session = Depends(get_db), user: UserClaims = Depends(require_auth)):
if not RoomService(db).exists(room_id, user.sub):
raise HTTPException(status_code=404, detail="Room not found")
return svc.list_messages(room_id, offset=offset, limit=limit)
return MessageService(db).list(room_id, user.sub, offset, limit)

View File

@@ -2,6 +2,8 @@ FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN mkdir -p /app/uploads
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .

View File

@@ -7,6 +7,9 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
RUN mkdir -p /app/uploads
RUN mkdir -p /app/uploads
COPY src ./src
COPY alembic.ini ./

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env sh
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

View File

@@ -1,5 +1,5 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db.session import get_db
@@ -23,42 +23,33 @@ def list_pairs(for_user_id: str | None = None, status: str | None = None,
_: UserClaims = Depends(get_current_user)):
return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit)
@router.get("/{pair_id}", response_model=PairRead)
def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
obj = PairService(db).get(pair_id)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
return obj
@router.get("/v1/pairs/{pair_id}", response_model=PairRead)
def get_pair(pair_id: UUID, db: Session = Depends(get_db), user: UserClaims = Depends(require_auth)):
pair = PairService(db).get(pair_id)
if not pair:
raise HTTPException(status_code=404, detail="Pair not found")
return pair
@router.patch("/{pair_id}", response_model=PairRead)
def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db),
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
svc = PairService(db)
obj = svc.get(pair_id)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
return svc.update(obj, **payload.model_dump(exclude_none=True))
@router.patch("/v1/pairs/{pair_id}", response_model=PairRead)
def update_pair(pair_id: UUID, payload: PairUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(require_admin)):
updated = PairService(db).update(pair_id, payload)
if not updated:
raise HTTPException(status_code=404, detail="Pair not found")
return updated
@router.post("/{pair_id}/accept", response_model=PairRead)
def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = PairService(db)
obj = svc.get(pair_id)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
# Validate that current user participates
if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
raise HTTPException(status_code=403, detail="Not allowed")
return svc.set_status(obj, "accepted")
@router.post("/v1/pairs/{pair_id}/accept", response_model=PairRead)
def accept(pair_id: UUID, db: Session = Depends(get_db), user: UserClaims = Depends(require_auth)):
res = PairService(db).accept(pair_id, user.sub)
if not res:
raise HTTPException(status_code=404, detail="Pair not found")
return res
@router.post("/{pair_id}/reject", response_model=PairRead)
def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = PairService(db)
obj = svc.get(pair_id)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
raise HTTPException(status_code=403, detail="Not allowed")
return svc.set_status(obj, "rejected")
@router.post("/v1/pairs/{pair_id}/reject", response_model=PairRead)
def reject(pair_id: UUID, db: Session = Depends(get_db), user: UserClaims = Depends(require_auth)):
res = PairService(db).reject(pair_id, user.sub)
if not res:
raise HTTPException(status_code=404, detail="Pair not found")
return res
@router.delete("/{pair_id}", status_code=204)
def delete_pair(pair_id: str, db: Session = Depends(get_db),

View File

@@ -7,6 +7,8 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
RUN mkdir -p /app/uploads
COPY src ./src
COPY alembic.ini ./

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env sh
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

View File

@@ -7,6 +7,7 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
RUN mkdir -p /app/uploads
COPY src ./src
COPY alembic.ini ./

View File

@@ -0,0 +1,28 @@
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_profile_photo_and_likes'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# photo_url
with op.batch_alter_table('profiles', schema=None) as batch_op:
batch_op.add_column(sa.Column('photo_url', sa.String(), nullable=True))
# likes
op.create_table(
'profile_likes',
sa.Column('liker_user_id', sa.String(), primary_key=True),
sa.Column('target_user_id', sa.String(), primary_key=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
)
op.create_unique_constraint('uq_profile_like', 'profile_likes', ['liker_user_id', 'target_user_id'])
def downgrade():
op.drop_constraint('uq_profile_like', 'profile_likes', type_='unique')
op.drop_table('profile_likes')
with op.batch_alter_table('profiles', schema=None) as batch_op:
batch_op.drop_column('photo_url')

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env sh
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 --log-level debug

View File

@@ -9,3 +9,4 @@ python-dotenv
httpx>=0.27
pytest
PyJWT>=2.8
python-multipart==0.0.9

View File

@@ -1,31 +1,143 @@
from fastapi import APIRouter, Depends, HTTPException, status
from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from sqlalchemy.orm import Session
from ...core.security import get_current_user, require_roles, UserClaims
from ...db.session import get_db
from ...models.profile import Profile
from ...schemas.profile import ProfileCreate, ProfileUpdate, ProfileOut, LikesList
from ...services.profile_service import ProfileService
from ...services.profile_search_service import ProfileSearchService
from ...services.likes_service import LikesService
import os
from fastapi import status
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
router = APIRouter(prefix="/v1/profiles", tags=["profiles"])
# отключаем авто-редирект /path -> /path/
router = APIRouter(prefix="/v1/profiles", tags=["profiles"], redirect_slashes=False)
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/app/uploads")
BASE_EXTERNAL_URL = os.getenv("BASE_EXTERNAL_URL", "http://localhost:8080")
@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
def get_me(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
prof = ProfileService(db).get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Profile not found")
return prof
@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)
@router.post("", response_model=ProfileOut, status_code=201)
def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
if svc.get_by_user_id(user.sub):
raise HTTPException(status_code=409, detail="Profile already exists")
return svc.create(user.sub, **payload.model_dump())
@router.get("", response_model=List[ProfileOut])
def list_profiles(q: Optional[str] = None,
gender: Optional[str] = Query(None, pattern="^(male|female|other)$"),
city: Optional[str] = None,
languages: Optional[List[str]] = Query(None),
interests: Optional[List[str]] = Query(None),
has_photo: Optional[bool] = None,
sort_by: Optional[str] = Query(None, pattern="^(created_at|updated_at|city|gender)$"),
order: Optional[str] = Query("asc", pattern="^(asc|desc)$"),
offset: int = 0, limit: int = Query(50, le=200),
db: Session = Depends(get_db),
user: UserClaims = Depends(get_current_user)):
# ADMIN видит всех; CLIENT — тоже ok для MVP
svc = ProfileSearchService(db)
return svc.list_profiles(q=q, gender=gender, city=city,
languages=languages, interests=interests, has_photo=has_photo,
sort_by=sort_by, order=order, offset=offset, limit=limit)
@router.get("/v1/profiles/{profile_id}", response_model=ProfileOut)
def get_profile(profile_id: UUID, db: Session = Depends(get_db), user: UserClaims = Depends(require_auth)):
prof = ProfileService(db).get(profile_id)
if not prof:
raise HTTPException(status_code=404, detail="Profile not found")
return prof
@router.get("/v1/profiles/by-user/{user_id}", response_model=ProfileOut)
def get_by_user(user_id: UUID, db: Session = Depends(get_db), user: UserClaims = Depends(require_auth)):
prof = ProfileService(db).get_by_user_id(user_id)
if not prof:
raise HTTPException(status_code=404, detail="Profile not found")
return prof
@router.patch("/me", response_model=ProfileOut)
def patch_me(payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
data = payload.model_dump(exclude_none=True)
return svc.update(prof, **data)
@router.delete("/me", status_code=204)
def delete_me(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
return
svc.delete(prof)
return
# === Photo upload ===
@router.post("/me/photo", response_model=ProfileOut)
def upload_photo(file: UploadFile = File(...),
db: Session = Depends(get_db),
user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
if file.content_type not in ("image/jpeg", "image/png", "image/webp"):
raise HTTPException(status_code=400, detail="Invalid content-type")
os.makedirs(UPLOAD_DIR, exist_ok=True)
ext = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" }[file.content_type]
subdir = os.path.join(UPLOAD_DIR, "avatars")
os.makedirs(subdir, exist_ok=True)
filename = f"{user.sub}.{ext}"
path = os.path.join(subdir, filename)
with open(path, "wb") as f:
f.write(file.file.read())
# URL через gateway: /profiles/static/avatars/<filename>
public_url = f"{BASE_EXTERNAL_URL}/profiles/static/avatars/{filename}"
return svc.update(prof, photo_url=public_url)
@router.delete("/me/photo", response_model=ProfileOut)
def delete_photo(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
return svc.update(prof, photo_url=None)
# === Likes ===
@router.get("/../likes", response_model=LikesList, include_in_schema=False)
def _compat_likes_redirect():
# Заглушка для генераторов — реальный path ниже (/v1/likes)
return LikesList(items=[])
likes_router = APIRouter(prefix="/v1/likes", tags=["profiles"])
@likes_router.get("", response_model=LikesList)
def my_likes(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
items = LikesService(db).list_my_likes(user.sub)
return LikesList(items=items)
@likes_router.put("/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT)
def put_like(target_user_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
LikesService(db).put_like(user.sub, target_user_id)
return
@likes_router.delete("/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_like(target_user_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
LikesService(db).delete_like(user.sub, target_user_id)
return
@likes_router.get("/mutual", response_model=List[str])
def mutual(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
return LikesService(db).mutual(user.sub)

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,147 @@
from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy.orm import Session
from ....core.security import get_current_user, require_roles, UserClaims
from ....db.session import get_db
from ....models.profile import Profile
from ....schemas.profile import ProfileCreate, ProfileUpdate, ProfileOut, LikesList
from ....services.profile_service import ProfileService
from ....services.profile_search_service import ProfileSearchService
from ....services.likes_service import LikesService
import os
from fastapi import status
router = APIRouter(prefix="/v1/profiles", tags=["profiles"])
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/app/uploads")
BASE_EXTERNAL_URL = os.getenv("BASE_EXTERNAL_URL", "http://localhost:8080")
@router.get("/me", response_model=ProfileOut)
def get_me(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
prof = ProfileService(db).get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Profile not found")
return prof
@router.post("", response_model=ProfileOut, status_code=201)
def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
if svc.get_by_user_id(user.sub):
raise HTTPException(status_code=409, detail="Profile already exists")
return svc.create(user.sub, **payload.model_dump())
@router.get("", response_model=List[ProfileOut])
def list_profiles(q: Optional[str] = None,
gender: Optional[str] = Query(None, pattern="^(male|female|other)$"),
city: Optional[str] = None,
languages: Optional[List[str]] = Query(None),
interests: Optional[List[str]] = Query(None),
has_photo: Optional[bool] = None,
sort_by: Optional[str] = Query(None, pattern="^(created_at|updated_at|city|gender)$"),
order: Optional[str] = Query("asc", pattern="^(asc|desc)$"),
offset: int = 0, limit: int = Query(50, le=200),
db: Session = Depends(get_db),
user: UserClaims = Depends(get_current_user)):
# ADMIN видит всех; CLIENT — тоже ok для MVP
svc = ProfileSearchService(db)
return svc.list_profiles(q=q, gender=gender, city=city,
languages=languages, interests=interests, has_photo=has_photo,
sort_by=sort_by, order=order, offset=offset, limit=limit)
@router.get("/{profile_id}", response_model=ProfileOut)
def get_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
prof = ProfileService(db).get(profile_id)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
if user.role != "ADMIN" and prof.user_id != user.sub:
# при необходимости можно сделать публичным — тут ограничение на владение/admin
pass
return prof
@router.get("/by-user/{user_id}", response_model=ProfileOut)
def get_by_user(user_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
prof = ProfileService(db).get_by_user_id(user_id)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
if user.role != "ADMIN" and user_id != user.sub:
pass
return prof
@router.patch("/me", response_model=ProfileOut)
def patch_me(payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
data = payload.model_dump(exclude_none=True)
return svc.update(prof, **data)
@router.delete("/me", status_code=204)
def delete_me(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
return
svc.delete(prof)
return
# === Photo upload ===
@router.post("/me/photo", response_model=ProfileOut)
def upload_photo(file: UploadFile = File(...),
db: Session = Depends(get_db),
user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
if file.content_type not in ("image/jpeg", "image/png", "image/webp"):
raise HTTPException(status_code=400, detail="Invalid content-type")
os.makedirs(UPLOAD_DIR, exist_ok=True)
ext = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" }[file.content_type]
subdir = os.path.join(UPLOAD_DIR, "avatars")
os.makedirs(subdir, exist_ok=True)
filename = f"{user.sub}.{ext}"
path = os.path.join(subdir, filename)
with open(path, "wb") as f:
f.write(file.file.read())
# URL через gateway: /profiles/static/avatars/<filename>
public_url = f"{BASE_EXTERNAL_URL}/profiles/static/avatars/{filename}"
return svc.update(prof, photo_url=public_url)
@router.delete("/me/photo", response_model=ProfileOut)
def delete_photo(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
return svc.update(prof, photo_url=None)
# === Likes ===
@router.get("/../likes", response_model=LikesList, include_in_schema=False)
def _compat_likes_redirect():
# Заглушка для генераторов — реальный path ниже (/v1/likes)
return LikesList(items=[])
likes_router = APIRouter(prefix="/v1/likes", tags=["profiles"])
@likes_router.get("", response_model=LikesList)
def my_likes(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
items = LikesService(db).list_my_likes(user.sub)
return LikesList(items=items)
@likes_router.put("/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT)
def put_like(target_user_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
LikesService(db).put_like(user.sub, target_user_id)
return
@likes_router.delete("/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_like(target_user_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
LikesService(db).delete_like(user.sub, target_user_id)
return
@likes_router.get("/mutual", response_model=List[str])
def mutual(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
return LikesService(db).mutual(user.sub)

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy.orm import Session
from ...core.security import get_current_user, require_roles, UserClaims
from ...db.session import get_db
from ...models.profile import Profile
from ...schemas.profile import ProfileCreate, ProfileUpdate, ProfileOut, LikesList
from ....services.profile_service import ProfileService
from ....services.profile_search_service import ProfileSearchService
from ....services.likes_service import LikesService
import os
from fastapi import status
router = APIRouter(prefix="/v1/profiles", tags=["profiles"])
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/app/uploads")
BASE_EXTERNAL_URL = os.getenv("BASE_EXTERNAL_URL", "http://localhost:8080")
@router.get("/me", response_model=ProfileOut)
def get_me(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
prof = ProfileService(db).get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Profile not found")
return prof
@router.post("", response_model=ProfileOut, status_code=201)
def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
if svc.get_by_user_id(user.sub):
raise HTTPException(status_code=409, detail="Profile already exists")
return svc.create(user.sub, **payload.model_dump())
@router.get("", response_model=List[ProfileOut])
def list_profiles(q: Optional[str] = None,
gender: Optional[str] = Query(None, pattern="^(male|female|other)$"),
city: Optional[str] = None,
languages: Optional[List[str]] = Query(None),
interests: Optional[List[str]] = Query(None),
has_photo: Optional[bool] = None,
sort_by: Optional[str] = Query(None, pattern="^(created_at|updated_at|city|gender)$"),
order: Optional[str] = Query("asc", pattern="^(asc|desc)$"),
offset: int = 0, limit: int = Query(50, le=200),
db: Session = Depends(get_db),
user: UserClaims = Depends(get_current_user)):
# ADMIN видит всех; CLIENT — тоже ok для MVP
svc = ProfileSearchService(db)
return svc.list_profiles(q=q, gender=gender, city=city,
languages=languages, interests=interests, has_photo=has_photo,
sort_by=sort_by, order=order, offset=offset, limit=limit)
@router.get("/{profile_id}", response_model=ProfileOut)
def get_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
prof = ProfileService(db).get(profile_id)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
if user.role != "ADMIN" and prof.user_id != user.sub:
# при необходимости можно сделать публичным — тут ограничение на владение/admin
pass
return prof
@router.get("/by-user/{user_id}", response_model=ProfileOut)
def get_by_user(user_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
prof = ProfileService(db).get_by_user_id(user_id)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
if user.role != "ADMIN" and user_id != user.sub:
pass
return prof
@router.patch("/me", response_model=ProfileOut)
def patch_me(payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
data = payload.model_dump(exclude_none=True)
return svc.update(prof, **data)
@router.delete("/me", status_code=204)
def delete_me(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
return
svc.delete(prof)
return
# === Photo upload ===
@router.post("/me/photo", response_model=ProfileOut)
def upload_photo(file: UploadFile = File(...),
db: Session = Depends(get_db),
user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
if file.content_type not in ("image/jpeg", "image/png", "image/webp"):
raise HTTPException(status_code=400, detail="Invalid content-type")
os.makedirs(UPLOAD_DIR, exist_ok=True)
ext = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" }[file.content_type]
subdir = os.path.join(UPLOAD_DIR, "avatars")
os.makedirs(subdir, exist_ok=True)
filename = f"{user.sub}.{ext}"
path = os.path.join(subdir, filename)
with open(path, "wb") as f:
f.write(file.file.read())
# URL через gateway: /profiles/static/avatars/<filename>
public_url = f"{BASE_EXTERNAL_URL}/profiles/static/avatars/{filename}"
return svc.update(prof, photo_url=public_url)
@router.delete("/me/photo", response_model=ProfileOut)
def delete_photo(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
prof = svc.get_by_user_id(user.sub)
if not prof:
raise HTTPException(status_code=404, detail="Not found")
return svc.update(prof, photo_url=None)
# === Likes ===
@router.get("/../likes", response_model=LikesList, include_in_schema=False)
def _compat_likes_redirect():
# Заглушка для генераторов — реальный path ниже (/v1/likes)
return LikesList(items=[])
likes_router = APIRouter(prefix="/v1/likes", tags=["profiles"])
@likes_router.get("", response_model=LikesList)
def my_likes(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
items = LikesService(db).list_my_likes(user.sub)
return LikesList(items=items)
@likes_router.put("/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT)
def put_like(target_user_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
LikesService(db).put_like(user.sub, target_user_id)
return
@likes_router.delete("/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_like(target_user_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
LikesService(db).delete_like(user.sub, target_user_id)
return
@likes_router.get("/mutual", response_model=List[str])
def mutual(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
return LikesService(db).mutual(user.sub)

View File

@@ -57,3 +57,64 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(reusabl
if credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth scheme")
return decode_token(credentials.credentials)
# --- added by patch: role-based dependency ---
from fastapi import Depends, HTTPException, status # noqa: E402
def require_roles(*roles: str):
"""
FastAPI dependency: ensure current user has one of the roles.
Usage: Depends(require_roles("ADMIN", "MATCHMAKER"))
"""
def _dep(user: "UserClaims" = Depends(get_current_user)):
if not getattr(user, "role", None) in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
return user
return _dep
# --- end patch ---
# --- added by patch: UserClaims + normalization ---
from typing import Optional, Any # noqa: E402
from pydantic import BaseModel # noqa: E402
class UserClaims(BaseModel):
sub: str
role: str
email: Optional[str] = None
def _as_user_claims(u: Any) -> "UserClaims":
"""Приводит произвольный объект пользователя к UserClaims."""
if isinstance(u, UserClaims):
return u
if isinstance(u, dict):
return UserClaims(**u)
if hasattr(u, "model_dump"): # pydantic v2
return UserClaims(**u.model_dump())
# на крайний случай — достанем атрибуты
return UserClaims(
sub=str(getattr(u, "sub", "")),
role=str(getattr(u, "role", "")),
email=getattr(u, "email", None),
)
# если есть require_roles — заставим его использовать нормализацию
try:
# find the already-patched require_roles and wrap its inner dep
import inspect
if 'require_roles' in globals():
_orig_require_roles = require_roles
def require_roles(*roles: str): # type: ignore[override]
dep = _orig_require_roles(*roles)
def _wrapped(user = Depends(get_current_user)): # noqa: F821
u = _as_user_claims(user)
if u.role not in roles:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
return u
# Вернём совместимый Depends
from fastapi import Depends
return _wrapped
except Exception:
# тихо игнорируем — значит require_roles ещё не определён, всё ок
pass
# --- end patch ---

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,74 @@
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)
# --- added by patch: role-based dependency ---
from fastapi import Depends, HTTPException, status # noqa: E402
def require_roles(*roles: str):
"""
FastAPI dependency: ensure current user has one of the roles.
Usage: Depends(require_roles("ADMIN", "MATCHMAKER"))
"""
def _dep(user: "UserClaims" = Depends(get_current_user)):
if not getattr(user, "role", None) in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
return user
return _dep
# --- end patch ---

View File

@@ -1,12 +1,17 @@
from fastapi import FastAPI
from starlette.staticfiles import StaticFiles
from .api.routes.ping import router as ping_router
from .api.routes.profiles import router as profiles_router
from .api.routes.profiles import router as profiles_router, likes_router as profiles_likes_router
app = FastAPI(title="PROFILES Service")
app.mount("/static", StaticFiles(directory="/app/uploads"), name="static")
@app.get("/health")
def health():
return {"status": "ok", "service": "profiles"}
app.include_router(ping_router, prefix="/v1")
app.include_router(profiles_router)
app.include_router(profiles_likes_router)

View File

@@ -0,0 +1,12 @@
from datetime import datetime
from sqlalchemy import Column, String, DateTime, UniqueConstraint
from .base import Base
class ProfileLike(Base):
__tablename__ = "profile_likes"
liker_user_id = Column(String, primary_key=True) # UUID (as str)
target_user_id = Column(String, primary_key=True) # UUID (as str)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
__table_args__ = (
UniqueConstraint('liker_user_id', 'target_user_id', name='uq_profile_like'),
)

View File

@@ -0,0 +1,37 @@
from typing import List
from sqlalchemy.orm import Session
from ..models.like import ProfileLike
class LikesRepository:
def __init__(self, db: Session):
self.db = db
def list_my_likes(self, user_id: str) -> List[str]:
rows = self.db.query(ProfileLike).filter(ProfileLike.liker_user_id == user_id).all()
return [r.target_user_id for r in rows]
def put_like(self, liker: str, target: str) -> None:
exists = self.db.query(ProfileLike).filter(
ProfileLike.liker_user_id == liker,
ProfileLike.target_user_id == target
).first()
if exists:
return
self.db.add(ProfileLike(liker_user_id=liker, target_user_id=target))
self.db.commit()
def delete_like(self, liker: str, target: str) -> None:
self.db.query(ProfileLike).filter(
ProfileLike.liker_user_id == liker,
ProfileLike.target_user_id == target
).delete()
self.db.commit()
def mutual_likes(self, user_id: str) -> List[str]:
# users that user_id likes AND who like user_id back
sub = self.db.query(ProfileLike.target_user_id).filter(ProfileLike.liker_user_id == user_id).subquery()
rows = self.db.query(ProfileLike.liker_user_id).filter(
ProfileLike.target_user_id == user_id,
ProfileLike.liker_user_id.in_(sub)
).all()
return [r[0] for r in rows]

View File

@@ -1,26 +1,81 @@
from typing import Optional
# services/profiles/src/app/repositories/profile_repository.py
from __future__ import annotations
from typing import List, 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 []),
def get_by_user_id(self, user_id: UUID) -> Optional[Profile]:
return (
self.db.query(Profile)
.filter(Profile.user_id == user_id)
.first()
)
self.db.add(p)
# оставляем старое имя для обратной совместимости
def get_by_user(self, user_id: UUID) -> Optional[Profile]:
return self.get_by_user_id(user_id)
def get_by_id(self, profile_id: UUID) -> Optional[Profile]:
return self.db.query(Profile).filter(Profile.id == profile_id).first()
def create(
self,
*,
user_id: UUID,
gender: str,
city: str,
languages: List[str],
interests: List[str],
photo_url: Optional[str] = None,
) -> Profile:
# не передаём photo_url в конструктор — модели может не быть этого поля
obj = Profile(
user_id=user_id,
gender=gender,
city=city,
languages=languages,
interests=interests,
)
# выставим только если поле реально существует
if photo_url is not None and hasattr(obj, "photo_url"):
setattr(obj, "photo_url", photo_url)
self.db.add(obj)
self.db.commit()
self.db.refresh(p)
return p
self.db.refresh(obj)
return obj
def update_me(
self,
*,
profile: Profile,
gender: Optional[str] = None,
city: Optional[str] = None,
languages: Optional[List[str]] = None,
interests: Optional[List[str]] = None,
photo_url: Optional[Optional[str]] = None, # None — «не менять», явный null — «стереть»
) -> Profile:
if gender is not None:
profile.gender = gender
if city is not None:
profile.city = city
if languages is not None:
profile.languages = languages
if interests is not None:
profile.interests = interests
if photo_url is not None and hasattr(profile, "photo_url"):
# сюда придёт либо строка (установить), либо явный None (стереть)
setattr(profile, "photo_url", photo_url)
self.db.commit()
self.db.refresh(profile)
return profile

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,55 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import or_, func, text
from ..models.profile import Profile
class ProfileSearchRepository:
def __init__(self, db: Session):
self.db = db
def list_profiles(self,
q: Optional[str],
gender: Optional[str],
city: Optional[str],
languages: Optional[List[str]],
interests: Optional[List[str]],
has_photo: Optional[bool],
sort_by: Optional[str],
order: Optional[str],
offset: int, limit: int) -> List[Profile]:
query = self.db.query(Profile)
if q:
ilike = f"%{q}%"
query = query.filter(
or_(Profile.city.ilike(ilike),
func.cast(Profile.languages, text('TEXT')).ilike(ilike),
func.cast(Profile.interests, text('TEXT')).ilike(ilike))
)
if gender:
query = query.filter(Profile.gender == gender)
if city:
query = query.filter(Profile.city.ilike(city) | (Profile.city == city))
if languages:
# пересечение хотя бы по одному
for l in languages:
query = query.filter(func.cast(Profile.languages, text('TEXT')).ilike(f'%"{l}"%'))
if interests:
for i in interests:
query = query.filter(func.cast(Profile.interests, text('TEXT')).ilike(f'%"{i}"%'))
if has_photo is True:
query = query.filter(Profile.photo_url.isnot(None))
if has_photo is False:
query = query.filter(Profile.photo_url.is_(None))
sort_map = {
"created_at": Profile.created_at if hasattr(Profile, "created_at") else None,
"updated_at": Profile.updated_at if hasattr(Profile, "updated_at") else None,
"city": Profile.city,
"gender": Profile.gender,
}
col = sort_map.get(sort_by or "", Profile.created_at if hasattr(Profile, "created_at") else Profile.id)
if order == "desc":
col = col.desc()
query = query.order_by(col)
return query.offset(offset).limit(min(limit, 200)).all()

View File

@@ -1,32 +1,77 @@
# from pydantic import BaseModel, Field, HttpUrl
# from typing import Optional, List, Literal
# from uuid import UUID
# class ProfileCreate(BaseModel):
# gender: Literal["male", "female", "other"]
# city: str
# languages: List[str] = []
# interests: List[str] = []
# class ProfileUpdate(BaseModel):
# gender: Optional[Literal["male", "female", "other"]] = None
# city: Optional[str] = None
# languages: Optional[List[str]] = None
# interests: Optional[List[str]] = None
# photo_url: Optional[Optional[str]] = Field(default=None)
# class ProfileOut(BaseModel):
# id: UUID
# user_id: UUID
# gender: Literal["male", "female", "other"]
# city: str
# languages: List[str] = []
# interests: List[str] = []
# photo_url: Optional[str] = None
# from pydantic import ConfigDict
# model_config = ConfigDict(from_attributes=True)
# class LikesList(BaseModel):
# items: List[str]
# services/profiles/src/app/schemas/profile.py
from __future__ import annotations
from typing import List, Optional
from uuid import UUID
from typing import List
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
from pydantic import BaseModel, ConfigDict
# Базовые поля профиля
class ProfileBase(BaseModel):
gender: str
city: str
languages: List[str] = Field(default_factory=list)
interests: List[str] = Field(default_factory=list)
languages: List[str]
interests: List[str]
# Для POST /profiles/v1/profiles
class ProfileCreate(ProfileBase):
pass
# Для PATCH /profiles/v1/profiles/me (все поля опциональные)
class ProfileUpdate(BaseModel):
gender: Optional[str] = None
city: Optional[str] = None
languages: Optional[List[str]] = None
interests: Optional[List[str]] = None
photo_url: Optional[str] = None # nullable
# Для ответов
class ProfileOut(ProfileBase):
id: UUID
user_id: UUID
photo_url: Optional[str] = None
if _V2:
model_config = ConfigDict(from_attributes=True)
else:
class Config:
orm_mode = True
# Важно: для возврата ORM-объектов
model_config = ConfigDict(from_attributes=True)
# Для лайков
class LikesList(BaseModel):
items: List[UUID]

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from uuid import UUID
from typing import List
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,27 @@
from pydantic import BaseModel, Field, HttpUrl
from typing import Optional, List, Literal
class ProfileCreate(BaseModel):
gender: Literal["male", "female", "other"]
city: str
languages: List[str] = []
interests: List[str] = []
class ProfileUpdate(BaseModel):
gender: Optional[Literal["male", "female", "other"]] = None
city: Optional[str] = None
languages: Optional[List[str]] = None
interests: Optional[List[str]] = None
photo_url: Optional[Optional[str]] = Field(default=None)
class ProfileOut(BaseModel):
id: str
user_id: str
gender: Literal["male", "female", "other"]
city: str
languages: List[str] = []
interests: List[str] = []
photo_url: Optional[str] = None
class LikesList(BaseModel):
items: List[str]

View File

@@ -0,0 +1,21 @@
from typing import List
from sqlalchemy.orm import Session
from ..repositories.likes_repository import LikesRepository
class LikesService:
def __init__(self, db: Session):
self.repo = LikesRepository(db)
def list_my_likes(self, user_id: str) -> List[str]:
return self.repo.list_my_likes(user_id)
def put_like(self, liker: str, target: str) -> None:
if liker == target:
return
self.repo.put_like(liker, target)
def delete_like(self, liker: str, target: str) -> None:
self.repo.delete_like(liker, target)
def mutual(self, user_id: str) -> List[str]:
return self.repo.mutual_likes(user_id)

View File

@@ -0,0 +1,11 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from ..models.profile import Profile
from ..repositories.profile_search_repository import ProfileSearchRepository
class ProfileSearchService:
def __init__(self, db: Session):
self.repo = ProfileSearchRepository(db)
def list_profiles(self, **kwargs) -> List[Profile]:
return self.repo.list_profiles(**kwargs)

View File

@@ -1,13 +1,23 @@
from uuid import UUID
from app.schemas.profile import ProfileCreate
from app.repositories.profile_repository import ProfileRepository
from typing import Optional
from sqlalchemy.orm import Session
from ..models.profile import Profile
from ..repositories.profile_repository import ProfileRepository
class ProfileService:
def __init__(self, repo: ProfileRepository):
self.repo = repo
def __init__(self, db: Session):
self.repo = ProfileRepository(db)
def get_by_user(self, user_id: UUID):
return self.repo.get_by_user(user_id)
def get(self, profile_id: str) -> Optional[Profile]:
return self.repo.get(profile_id)
def create(self, user_id: UUID, data: ProfileCreate):
return self.repo.create(user_id, data)
def get_by_user_id(self, user_id: str) -> Optional[Profile]:
return self.repo.get_by_user_id(user_id)
def create(self, user_id: str, **data) -> Profile:
return self.repo.create(user_id=user_id, **data)
def update(self, prof: Profile, **data) -> Profile:
return self.repo.update(prof, **data)
def delete(self, prof: Profile) -> None:
return self.repo.delete(prof)

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)