Initial commit

This commit is contained in:
belviskhoremk
2026-03-06 22:57:58 +00:00
commit c4d836a0f9
60 changed files with 5423 additions and 0 deletions

View File

View File

@@ -0,0 +1,82 @@
"""Agency CRUD endpoints."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, Query
from app.middleware.auth import get_current_user, require_admin, require_agency_or_admin
from app.schemas.agency import (
AgencyCreate,
AgencyListResponse,
AgencyResponse,
AgencyUpdate,
)
from app.services.agency_service import AgencyService
router = APIRouter(prefix="/agencies", tags=["Agencies"])
@router.get("/", response_model=AgencyListResponse)
def list_agencies(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
verified_only: bool = Query(False),
):
svc = AgencyService()
return svc.list_agencies(page=page, page_size=page_size, verified_only=verified_only)
@router.get("/me", response_model=AgencyResponse)
def get_my_agency(user: dict = Depends(get_current_user)):
svc = AgencyService()
return svc.get_agency_by_user(user["id"])
@router.get("/{agency_id}", response_model=AgencyResponse)
def get_agency(agency_id: str):
svc = AgencyService()
return svc.get_agency(agency_id)
@router.post("/", response_model=AgencyResponse, status_code=201)
def create_agency(body: AgencyCreate, user: dict = Depends(get_current_user)):
svc = AgencyService()
# Prevent duplicate agencies for the same user
try:
existing = svc.get_agency_by_user(user["id"])
return existing
except Exception:
pass
return svc.create_agency(user["id"], body.model_dump())
@router.patch("/{agency_id}", response_model=AgencyResponse)
def update_agency(
agency_id: str,
body: AgencyUpdate,
user: dict = Depends(get_current_user),
):
svc = AgencyService()
return svc.update_agency(
agency_id, user["id"], user["role"], body.model_dump(exclude_unset=True)
)
@router.post("/{agency_id}/verify", response_model=AgencyResponse)
def verify_agency(agency_id: str, admin: dict = Depends(require_admin)):
svc = AgencyService()
return svc.verify_agency(agency_id, admin["role"])
@router.post("/{agency_id}/revoke", response_model=AgencyResponse)
def revoke_agency_verification(agency_id: str, admin: dict = Depends(require_admin)):
svc = AgencyService()
return svc.revoke_verification(agency_id, admin["role"])
@router.delete("/{agency_id}")
def delete_agency(agency_id: str, _admin: dict = Depends(require_admin)):
svc = AgencyService()
return svc.delete_agency(agency_id)

View File

@@ -0,0 +1,81 @@
"""Authentication endpoints — register, login, refresh, password reset."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.middleware.auth import get_current_user
from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
MessageResponse,
PasswordResetConfirm,
PasswordResetRequest,
RefreshTokenRequest,
RegisterRequest,
RegisterResponse,
)
from app.services.auth_service import AuthService
router = APIRouter(prefix="/auth", tags=["Authentication"])
@router.post("/register", status_code=201, response_model=RegisterResponse)
def register(body: RegisterRequest):
svc = AuthService()
return svc.register(
email=body.email,
password=body.password,
name=body.name,
role=body.role,
)
@router.post("/login")
def login(body: LoginRequest):
svc = AuthService()
return svc.login(email=body.email, password=body.password)
@router.post("/refresh")
def refresh(body: RefreshTokenRequest):
svc = AuthService()
return svc.refresh(body.refresh_token)
@router.post("/password-reset/request", response_model=MessageResponse)
def request_password_reset(body: PasswordResetRequest):
svc = AuthService()
msg = svc.request_password_reset(body.email)
return {"message": msg}
@router.post("/password-reset/confirm", response_model=MessageResponse)
def confirm_password_reset(
body: PasswordResetConfirm,
user: dict = Depends(get_current_user),
):
svc = AuthService()
return svc.reset_password(user["id"], body.new_password)
@router.post("/change-password", response_model=MessageResponse)
def change_password(
body: ChangePasswordRequest,
user: dict = Depends(get_current_user),
):
svc = AuthService()
return svc.change_password(
user["id"], user["email"], body.current_password, body.new_password
)
@router.post("/resend-verification", response_model=MessageResponse)
def resend_verification(user: dict = Depends(get_current_user)):
svc = AuthService()
return svc.resend_verification(user["email"])
@router.get("/me")
def get_me(user: dict = Depends(get_current_user)):
return user

View File

@@ -0,0 +1,56 @@
"""Category CRUD endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.middleware.auth import require_admin
from app.schemas.category import (
CategoryCreate,
CategoryListResponse,
CategoryResponse,
CategoryUpdate,
)
from app.services.category_service import CategoryService
router = APIRouter(prefix="/categories", tags=["Categories"])
@router.get("/", response_model=CategoryListResponse)
def list_categories():
svc = CategoryService()
return svc.list_categories()
@router.get("/{category_id}", response_model=CategoryResponse)
def get_category(category_id: str):
svc = CategoryService()
return svc.get_category(category_id)
@router.get("/slug/{slug}", response_model=CategoryResponse)
def get_category_by_slug(slug: str):
svc = CategoryService()
return svc.get_category_by_slug(slug)
@router.post("/", response_model=CategoryResponse, status_code=201)
def create_category(body: CategoryCreate, _admin: dict = Depends(require_admin)):
svc = CategoryService()
return svc.create_category(body.model_dump())
@router.patch("/{category_id}", response_model=CategoryResponse)
def update_category(
category_id: str,
body: CategoryUpdate,
_admin: dict = Depends(require_admin),
):
svc = CategoryService()
return svc.update_category(category_id, body.model_dump(exclude_unset=True))
@router.delete("/{category_id}")
def delete_category(category_id: str, _admin: dict = Depends(require_admin)):
svc = CategoryService()
return svc.delete_category(category_id)

View File

@@ -0,0 +1,35 @@
"""Favorites / wishlist endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.middleware.auth import get_current_user
from app.schemas.favorite import FavoriteCreate, FavoriteListResponse, FavoriteResponse
from app.services.favorite_service import FavoriteService
router = APIRouter(prefix="/favorites", tags=["Favorites"])
@router.post("/", response_model=FavoriteResponse, status_code=201)
def add_favorite(body: FavoriteCreate, user: dict = Depends(get_current_user)):
svc = FavoriteService()
return svc.add_favorite(user["id"], body.listing_id)
@router.delete("/{listing_id}")
def remove_favorite(listing_id: str, user: dict = Depends(get_current_user)):
svc = FavoriteService()
return svc.remove_favorite(user["id"], listing_id)
@router.get("/", response_model=FavoriteListResponse)
def list_favorites(user: dict = Depends(get_current_user)):
svc = FavoriteService()
return svc.list_favorites(user["id"])
@router.get("/check/{listing_id}")
def check_favorite(listing_id: str, user: dict = Depends(get_current_user)):
svc = FavoriteService()
return {"is_favorited": svc.is_favorited(user["id"], listing_id)}

View File

@@ -0,0 +1,185 @@
"""Listing CRUD + search endpoints."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, Query
from app.middleware.auth import get_current_user, get_optional_user, require_admin
from app.schemas.listing import (
ListingCreate,
ListingListResponse,
ListingResponse,
ListingStatusUpdate,
ListingUpdate,
)
from app.services.agency_service import AgencyService
from app.services.listing_service import ListingService
router = APIRouter(prefix="/listings", tags=["Listings"])
# ── Public ───────────────────────────────────────────────
@router.get("/", response_model=ListingListResponse)
def list_listings(
search: Optional[str] = None,
category: Optional[str] = None,
agency_id: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
location: Optional[str] = None,
listing_type: Optional[str] = None,
condition: Optional[str] = None,
sort_by: str = Query("newest"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
):
svc = ListingService()
return svc.list_listings(
search=search,
category=category,
agency_id=agency_id,
min_price=min_price,
max_price=max_price,
location=location,
listing_type=listing_type,
condition=condition,
sort_by=sort_by,
page=page,
page_size=page_size,
status="approved",
)
@router.get("/featured", response_model=ListingListResponse)
def featured_listings():
"""Return top 8 most viewed approved listings."""
svc = ListingService()
return svc.list_listings(
sort_by="popular",
page=1,
page_size=8,
status="approved",
)
@router.get("/{listing_id}", response_model=ListingResponse)
def get_listing(
listing_id: str,
background_tasks: BackgroundTasks,
user: Optional[dict] = Depends(get_optional_user),
):
svc = ListingService()
listing = svc.get_listing(listing_id)
background_tasks.add_task(svc.increment_views, listing_id)
return listing
# ── Agency ───────────────────────────────────────────────
@router.get("/agency/mine", response_model=ListingListResponse)
def my_listings(
status: Optional[str] = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: dict = Depends(get_current_user),
):
agency_svc = AgencyService()
agency = agency_svc.get_agency_by_user(user["id"])
svc = ListingService()
return svc.list_listings(
agency_id=agency["id"],
status=status,
page=page,
page_size=page_size,
)
@router.post("/", response_model=ListingResponse, status_code=201)
def create_listing(body: ListingCreate, user: dict = Depends(get_current_user)):
agency_svc = AgencyService()
agency = agency_svc.get_agency_by_user(user["id"])
svc = ListingService()
return svc.create_listing(agency["id"], body.model_dump())
@router.patch("/{listing_id}", response_model=ListingResponse)
def update_listing(
listing_id: str,
body: ListingUpdate,
user: dict = Depends(get_current_user),
):
svc = ListingService()
return svc.update_listing(
listing_id, user["id"], user["role"], body.model_dump(exclude_unset=True)
)
@router.delete("/{listing_id}")
def delete_listing(listing_id: str, user: dict = Depends(get_current_user)):
svc = ListingService()
return svc.delete_listing(listing_id, user["id"], user["role"])
# ── Admin ────────────────────────────────────────────────
@router.get("/admin/all", response_model=ListingListResponse)
def admin_list_all(
status: Optional[str] = None,
search: Optional[str] = None,
category: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
location: Optional[str] = None,
listing_type: Optional[str] = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
_admin: dict = Depends(require_admin),
):
svc = ListingService()
return svc.list_listings(
search=search,
status=status,
category=category,
min_price=min_price,
max_price=max_price,
location=location,
listing_type=listing_type,
page=page,
page_size=page_size,
)
@router.patch("/{listing_id}/status", response_model=ListingResponse)
def update_listing_status(
listing_id: str,
body: ListingStatusUpdate,
_admin: dict = Depends(require_admin),
):
svc = ListingService()
return svc.update_status(listing_id, body.status, body.rejection_reason)
@router.get("/stats/overview")
def listing_stats(
agency_id: Optional[str] = None,
user: dict = Depends(get_current_user),
):
svc = ListingService()
if user["role"] != "admin":
# Non-admins always see their own agency's stats only
agency_svc = AgencyService()
try:
agency = agency_svc.get_agency_by_user(user["id"])
agency_id = agency["id"]
except Exception:
return {"total": 0, "pending": 0, "approved": 0, "rejected": 0}
return svc.get_stats(agency_id)

View File

@@ -0,0 +1,74 @@
"""Contact / message endpoints."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, Query
from app.middleware.auth import get_current_user
from app.schemas.message import (
MessageCreate,
MessageListResponse,
MessageMarkRead,
MessageResponse,
)
from app.services.agency_service import AgencyService
from app.services.message_service import MessageService
router = APIRouter(prefix="/messages", tags=["Messages"])
@router.post("/", response_model=MessageResponse, status_code=201)
def send_message(body: MessageCreate):
"""Public endpoint — anyone can send a message about a listing."""
svc = MessageService()
return svc.send_message(body.model_dump())
@router.get("/", response_model=MessageListResponse)
def list_messages(
read: Optional[bool] = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: dict = Depends(get_current_user),
):
"""List messages for the current user's agency."""
agency_svc = AgencyService()
agency = agency_svc.get_agency_by_user(user["id"])
svc = MessageService()
return svc.list_messages(
agency_id=agency["id"],
user_id=user["id"],
user_role=user["role"],
read_filter=read,
page=page,
page_size=page_size,
)
@router.get("/unread-count")
def unread_count(user: dict = Depends(get_current_user)):
agency_svc = AgencyService()
agency = agency_svc.get_agency_by_user(user["id"])
svc = MessageService()
count = svc.get_unread_count(agency["id"])
return {"unread_count": count}
@router.patch("/{message_id}/read", response_model=MessageResponse)
def mark_message_read(
message_id: str,
body: MessageMarkRead,
user: dict = Depends(get_current_user),
):
svc = MessageService()
return svc.mark_read(message_id, user["id"], user["role"], body.read)
@router.delete("/{message_id}")
def delete_message(message_id: str, user: dict = Depends(get_current_user)):
svc = MessageService()
return svc.delete_message(message_id, user["id"], user["role"])

View File

@@ -0,0 +1,80 @@
"""Payment endpoints — subscription and purchase flows via CinetPay."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request, Response
from app.middleware.auth import get_current_user, require_agency
from app.schemas.payment import (
PaymentInitiate,
PaymentInitiateResponse,
PaymentReceiptResponse,
PaymentResponse,
SubscriptionResponse,
)
from app.services.payment_service import PaymentService
router = APIRouter(tags=["Payments"])
# ── Payments ─────────────────────────────────────────────
@router.post("/payments/initiate", response_model=PaymentInitiateResponse, status_code=201)
def initiate_payment(
body: PaymentInitiate,
user: dict = Depends(get_current_user),
):
svc = PaymentService()
return svc.initiate(
user_id=user["id"],
payment_type=body.type,
plan=body.plan,
listing_id=body.listing_id,
)
@router.post("/payments/webhook")
async def cinetpay_webhook(request: Request):
"""Receive CinetPay webhook — no auth required. Returns 200 immediately."""
try:
form = await request.form()
form_data = dict(form)
except Exception:
form_data = {}
# Also accept query params (CinetPay may use GET in some flows)
if not form_data:
form_data = dict(request.query_params)
svc = PaymentService()
try:
svc.handle_webhook(form_data)
except Exception:
pass # Always return 200 to CinetPay
return Response(status_code=200)
@router.get("/payments/", response_model=list[PaymentResponse])
def list_my_payments(user: dict = Depends(get_current_user)):
svc = PaymentService()
return svc.get_my_payments(user["id"])
@router.get("/payments/{transaction_id}", response_model=PaymentReceiptResponse)
def get_payment_receipt(
transaction_id: str,
user: dict = Depends(get_current_user),
):
svc = PaymentService()
return svc.get_receipt(transaction_id, user["id"])
# ── Subscriptions ─────────────────────────────────────────
@router.get("/subscriptions/me")
def my_subscription(user: dict = Depends(require_agency)):
svc = PaymentService()
return svc.get_subscription_status(user["id"])

View File

@@ -0,0 +1,53 @@
"""File upload endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, File, UploadFile
from app.middleware.auth import get_current_user
from app.services.upload_service import UploadService
router = APIRouter(prefix="/uploads", tags=["Uploads"])
@router.post("/image")
async def upload_image(
file: UploadFile = File(...),
user: dict = Depends(get_current_user),
):
"""Upload an image and return its public URL."""
contents = await file.read()
svc = UploadService()
url = svc.upload_image(
file_bytes=contents,
content_type=file.content_type or "image/jpeg",
folder=f"users/{user['id']}",
)
return {"url": url}
@router.post("/images")
async def upload_multiple_images(
files: list[UploadFile] = File(...),
user: dict = Depends(get_current_user),
):
"""Upload multiple images and return their public URLs."""
svc = UploadService()
urls = []
for f in files:
contents = await f.read()
url = svc.upload_image(
file_bytes=contents,
content_type=f.content_type or "image/jpeg",
folder=f"users/{user['id']}",
)
urls.append(url)
return {"urls": urls}
@router.delete("/")
async def delete_image(url: str, user: dict = Depends(get_current_user)):
"""Delete an image by URL."""
svc = UploadService()
success = svc.delete_image(url)
return {"deleted": success}

View File

@@ -0,0 +1,55 @@
"""User management endpoints."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, Query
from app.middleware.auth import get_current_user, require_admin
from app.schemas.user import UserListResponse, UserResponse, UserUpdate
from app.services.user_service import UserService
router = APIRouter(prefix="/users", tags=["Users"])
@router.get("/me", response_model=UserResponse)
def get_my_profile(user: dict = Depends(get_current_user)):
svc = UserService()
return svc.get_user(user["id"])
@router.patch("/me", response_model=UserResponse)
def update_my_profile(body: UserUpdate, user: dict = Depends(get_current_user)):
svc = UserService()
return svc.update_user(user["id"], body.model_dump(exclude_unset=True))
@router.get("/{user_id}", response_model=UserResponse)
def get_user(user_id: str, _admin: dict = Depends(require_admin)):
svc = UserService()
return svc.get_user(user_id)
@router.get("/", response_model=UserListResponse)
def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
role: Optional[str] = None,
search: Optional[str] = None,
_admin: dict = Depends(require_admin),
):
svc = UserService()
return svc.list_users(page=page, page_size=page_size, role=role, search=search)
@router.post("/{user_id}/verify", response_model=UserResponse)
def verify_user(user_id: str, _admin: dict = Depends(require_admin)):
svc = UserService()
return svc.verify_user(user_id)
@router.delete("/{user_id}")
def delete_user(user_id: str, user: dict = Depends(get_current_user)):
svc = UserService()
return svc.delete_user(user_id, user["id"], user["role"])