RABAC felépítése megtörtént ill hirdetési portál alapok lerakva

This commit is contained in:
Roo
2026-06-19 06:06:34 +00:00
parent fe3c32597d
commit 9ba2d9180d
30 changed files with 4336 additions and 76 deletions

View File

@@ -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"])

View File

@@ -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,

View 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,
)

View 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