RABAC felépítése megtörtént ill hirdetési portál alapok lerakva
This commit is contained in:
@@ -5,7 +5,8 @@ from app.api.v1.endpoints import (
|
||||
services, admin, expenses, evidence, social, security,
|
||||
billing, finance_admin, analytics, vehicles, system_parameters,
|
||||
gamification, translations, users, reports, dictionaries,
|
||||
admin_packages, admin_services, constants, providers
|
||||
admin_packages, admin_services, constants, providers,
|
||||
subscriptions, marketing,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -35,4 +36,6 @@ api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=
|
||||
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
|
||||
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
|
||||
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
|
||||
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
|
||||
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
|
||||
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
|
||||
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])
|
||||
@@ -7,7 +7,7 @@ from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User
|
||||
from app.models import Asset # JAVÍTVA: Asset modell
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,6 +18,7 @@ async def scan_registration_document(file: UploadFile = File(...), db: AsyncSess
|
||||
# ── P0 FEATURE: Quota engine sync — read org's subscription_tier rules ──
|
||||
# Instead of hardcoded plan-based lookup from system_parameters,
|
||||
# we now read the organization's assigned subscription_tier rules.
|
||||
# P0 BOOSTER: extra_allowances.extra_vehicles is added on top.
|
||||
max_allowed = 1 # default fallback
|
||||
|
||||
if current_user.scope_id:
|
||||
@@ -37,6 +38,22 @@ async def scan_registration_document(file: UploadFile = File(...), db: AsyncSess
|
||||
elif org:
|
||||
# Fallback to legacy base_asset_limit if no tier assigned
|
||||
max_allowed = max(org.base_asset_limit or 1, 1)
|
||||
|
||||
# ── P0 BOOSTER: Add extra_allowances.extra_vehicles ──
|
||||
try:
|
||||
org_sub_stmt = select(OrganizationSubscription.extra_allowances).where(
|
||||
OrganizationSubscription.org_id == current_user.scope_id,
|
||||
OrganizationSubscription.is_active == True
|
||||
).limit(1)
|
||||
org_sub_result = await db.execute(org_sub_stmt)
|
||||
extra_allowances = org_sub_result.scalar_one_or_none()
|
||||
if extra_allowances and isinstance(extra_allowances, dict):
|
||||
extra_vehicles = int(extra_allowances.get('extra_vehicles', 0))
|
||||
max_allowed += extra_vehicles
|
||||
if extra_vehicles > 0:
|
||||
logger.info(f"[P0 BOOSTER] Extra vehicles for org {current_user.scope_id}: +{extra_vehicles}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read extra_allowances for org {current_user.scope_id}: {e}")
|
||||
|
||||
stmt_count = select(func.count(Asset.id)).where(
|
||||
Asset.owner_org_id == current_user.scope_id,
|
||||
|
||||
136
backend/app/api/v1/endpoints/marketing.py
Normal file
136
backend/app/api/v1/endpoints/marketing.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/marketing.py
|
||||
"""
|
||||
Marketing & Ad Placement Public API Endpoints.
|
||||
|
||||
Provides:
|
||||
- GET /placements/{zone} — Get active ads for a specific placement zone
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.marketing import (
|
||||
Placement,
|
||||
CampaignPlacement,
|
||||
Campaign,
|
||||
Creative,
|
||||
CampaignStatus,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AdResponse(BaseModel):
|
||||
"""Egy hirdetés válaszmodellje a frontend számára."""
|
||||
|
||||
id: int
|
||||
campaign_id: int
|
||||
campaign_name: str
|
||||
creative_id: int
|
||||
creative_type: str
|
||||
content_url: Optional[str] = None
|
||||
html_snippet: Optional[str] = None
|
||||
alt_text: Optional[str] = None
|
||||
target_url: Optional[str] = None
|
||||
priority: int = 0
|
||||
|
||||
|
||||
class PlacementResponse(BaseModel):
|
||||
"""Egy placement zóna összes aktív hirdetésének válasza."""
|
||||
|
||||
zone: str
|
||||
ads: List[AdResponse]
|
||||
|
||||
|
||||
@router.get("/placements/{zone}", response_model=PlacementResponse)
|
||||
async def get_placement_ads(
|
||||
zone: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Aktív hirdetések lekérése egy adott placement zónához.
|
||||
|
||||
**Logika:**
|
||||
1. Megkeresi a Placement rekordot a `name` alapján (case-insensitive).
|
||||
2. Lekéri az összes aktív kampányhoz tartozó CampaignPlacement kapcsolatot.
|
||||
3. Visszaadja a kapcsolódó Creative-okat prioritás szerint rendezve.
|
||||
4. Ha nincs aktív kampány, üres listával tér vissza (nem dob hibát).
|
||||
"""
|
||||
# 1. Find the placement by name (case-insensitive)
|
||||
placement_stmt = select(Placement).where(
|
||||
Placement.name.ilike(zone),
|
||||
Placement.is_active == True,
|
||||
)
|
||||
result = await db.execute(placement_stmt)
|
||||
placement = result.scalar_one_or_none()
|
||||
|
||||
if not placement:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Placement zone '{zone}' not found",
|
||||
)
|
||||
|
||||
# 2. Find active campaign-placement links with eager-loaded relations
|
||||
campaign_placements_stmt = (
|
||||
select(CampaignPlacement)
|
||||
.options(
|
||||
selectinload(CampaignPlacement.campaign),
|
||||
selectinload(CampaignPlacement.creative),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
CampaignPlacement.placement_id == placement.id,
|
||||
CampaignPlacement.is_active == True,
|
||||
)
|
||||
)
|
||||
.order_by(CampaignPlacement.priority.asc())
|
||||
)
|
||||
result = await db.execute(campaign_placements_stmt)
|
||||
campaign_placements = result.scalars().all()
|
||||
|
||||
# 3. Filter: only include CampaignPlacements where the campaign is ACTIVE
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
active_ads: List[AdResponse] = []
|
||||
for cp in campaign_placements:
|
||||
campaign = cp.campaign
|
||||
creative = cp.creative
|
||||
|
||||
if not campaign or not creative:
|
||||
continue
|
||||
|
||||
# Check campaign status
|
||||
if campaign.status != CampaignStatus.ACTIVE.value:
|
||||
continue
|
||||
|
||||
# Check campaign date range
|
||||
if campaign.start_date and campaign.start_date > now_utc:
|
||||
continue
|
||||
if campaign.end_date and campaign.end_date < now_utc:
|
||||
continue
|
||||
|
||||
active_ads.append(
|
||||
AdResponse(
|
||||
id=cp.id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_name=campaign.name,
|
||||
creative_id=creative.id,
|
||||
creative_type=creative.creative_type,
|
||||
content_url=creative.content_url,
|
||||
html_snippet=creative.html_snippet,
|
||||
alt_text=creative.alt_text,
|
||||
target_url=creative.target_url,
|
||||
priority=cp.priority,
|
||||
)
|
||||
)
|
||||
|
||||
return PlacementResponse(
|
||||
zone=zone,
|
||||
ads=active_ads,
|
||||
)
|
||||
176
backend/app/api/v1/endpoints/subscriptions.py
Normal file
176
backend/app/api/v1/endpoints/subscriptions.py
Normal file
@@ -0,0 +1,176 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/subscriptions.py
|
||||
"""
|
||||
Subscription Public API Endpoints.
|
||||
|
||||
Provides:
|
||||
- GET /public — Public package catalog with dynamic pricing resolution
|
||||
based on the user's organization country (or fallback to DEFAULT zone).
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api import deps
|
||||
from app.db.session import get_db
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
from app.models.identity import User
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.schemas.subscription import (
|
||||
PublicSubscriptionTierResponse,
|
||||
SubscriptionTierResponse,
|
||||
PricingZoneModel,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── Pricing Resolution ────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_COUNTRY = "DEFAULT"
|
||||
|
||||
|
||||
def resolve_pricing(
|
||||
rules: dict,
|
||||
country_code: str,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Feloldja a megfelelő árazást a `pricing_zones` JSONB mezőből
|
||||
a felhasználó országkódja alapján.
|
||||
|
||||
**Logika:**
|
||||
1. Ha a `rules` tartalmaz `pricing_zones`-t, megkeresi a country_code-nak
|
||||
megfelelő zónát.
|
||||
2. Ha nincs pontos egyezés, visszaesik a "DEFAULT" zónára.
|
||||
3. Ha a `pricing_zones` hiányzik, visszaesik a legacy `pricing` mezőre.
|
||||
4. Ha semmi sincs, None-t ad vissza.
|
||||
"""
|
||||
if not rules or not isinstance(rules, dict):
|
||||
return None
|
||||
|
||||
# 1. Try pricing_zones first
|
||||
pricing_zones = rules.get("pricing_zones")
|
||||
if pricing_zones and isinstance(pricing_zones, dict):
|
||||
# Exact match
|
||||
if country_code in pricing_zones:
|
||||
return pricing_zones[country_code]
|
||||
# Fallback to DEFAULT
|
||||
if "DEFAULT" in pricing_zones:
|
||||
return pricing_zones["DEFAULT"]
|
||||
|
||||
# 2. Fallback to legacy pricing
|
||||
legacy_pricing = rules.get("pricing")
|
||||
if legacy_pricing and isinstance(legacy_pricing, dict):
|
||||
return {
|
||||
"monthly_price": legacy_pricing.get("monthly_price", 0),
|
||||
"yearly_price": legacy_pricing.get("yearly_price", 0),
|
||||
"currency": legacy_pricing.get("currency", "EUR"),
|
||||
"credit_price": legacy_pricing.get("credit_price"),
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_country_code(
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
) -> str:
|
||||
"""
|
||||
Meghatározza a felhasználó országkódját.
|
||||
|
||||
**Logika:**
|
||||
1. Ha a felhasználónak van aktív szervezete (active_organization_id),
|
||||
lekéri annak country_code mezőjét.
|
||||
2. Ha nincs aktív szervezet, a felhasználó Person rekordjának
|
||||
address országát próbálja meg lekérni.
|
||||
3. Ha egyik sem elérhető, visszaadja a "DEFAULT" értéket.
|
||||
"""
|
||||
# 1. Try active organization
|
||||
active_org_id = getattr(current_user, "active_organization_id", None)
|
||||
if active_org_id:
|
||||
org_stmt = select(Organization.country_code).where(
|
||||
Organization.id == active_org_id,
|
||||
Organization.is_deleted == False,
|
||||
)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_country = org_result.scalar_one_or_none()
|
||||
if org_country:
|
||||
return org_country.upper()
|
||||
|
||||
# 2. Try user's person -> address country
|
||||
person = getattr(current_user, "person", None)
|
||||
if person:
|
||||
address = getattr(person, "address", None)
|
||||
if address:
|
||||
country = getattr(address, "country", None)
|
||||
if country:
|
||||
return country.upper()
|
||||
|
||||
# 3. Fallback
|
||||
return DEFAULT_COUNTRY
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/public",
|
||||
response_model=List[PublicSubscriptionTierResponse],
|
||||
summary="Publikus csomagok listázása feloldott árazással",
|
||||
description=(
|
||||
"Visszaadja az összes publikus előfizetési csomagot, "
|
||||
"a felhasználó régiójára feloldott árazással (resolved_pricing). "
|
||||
"A feloldás a pricing_zones JSONB mezőből történik a felhasználó "
|
||||
"országkódja alapján."
|
||||
),
|
||||
)
|
||||
async def get_public_subscriptions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
):
|
||||
"""
|
||||
Publikus előfizetési csomagok listázása dinamikus árazással.
|
||||
|
||||
**Szabály:**
|
||||
- Csak azokat a csomagokat adja vissza, ahol a `rules.lifecycle.is_public` = true
|
||||
(JSONB mező alapján szűrve). Ha a mező hiányzik, alapértelmezés szerint publikus.
|
||||
- Minden csomaghoz tartozik egy `resolved_pricing` mező, amely a felhasználó
|
||||
országkódja alapján feloldott árazást tartalmazza.
|
||||
- A frontend tovább szűrhet a `name` mező alapján (private_ vs corp_ előtag).
|
||||
"""
|
||||
# 1. Determine user's country code
|
||||
country_code = await get_user_country_code(current_user, db)
|
||||
|
||||
# 2. Fetch all public tiers
|
||||
stmt = (
|
||||
select(SubscriptionTier)
|
||||
.order_by(SubscriptionTier.id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
tiers = result.scalars().all()
|
||||
|
||||
# 3. Build response with resolved pricing
|
||||
response: List[PublicSubscriptionTierResponse] = []
|
||||
for t in tiers:
|
||||
rules = t.rules or {}
|
||||
if not rules.get("lifecycle", {}).get("is_public", True):
|
||||
continue
|
||||
|
||||
# Resolve pricing for this user's country
|
||||
resolved = resolve_pricing(rules, country_code)
|
||||
resolved_pricing = None
|
||||
if resolved:
|
||||
resolved_pricing = PricingZoneModel(**resolved)
|
||||
|
||||
response.append(
|
||||
PublicSubscriptionTierResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
rules=rules,
|
||||
is_custom=t.is_custom,
|
||||
resolved_pricing=resolved_pricing,
|
||||
)
|
||||
)
|
||||
|
||||
return response
|
||||
Reference in New Issue
Block a user