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
|
||||
@@ -50,6 +50,13 @@ from .identity.security import PendingAction, ActionStatus
|
||||
from .system.legal import LegalDocument, LegalAcceptance
|
||||
from .marketplace.logistics import Location, LocationType
|
||||
|
||||
# 10. Marketing / Ad Engine models
|
||||
from .marketing import (
|
||||
Campaign, Creative, Placement, CampaignCreative,
|
||||
CampaignPlacement, AdImpression, AdClick,
|
||||
CampaignStatus, CreativeType, PlacementType,
|
||||
)
|
||||
|
||||
|
||||
# Aliasok a Digital Twin kompatibilitáshoz
|
||||
Vehicle = Asset
|
||||
@@ -81,5 +88,9 @@ __all__ = [
|
||||
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition", "ReferenceLookup",
|
||||
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
|
||||
"Location", "LocationType", "Issuer", "IssuerType", "CostCategory", "VehicleCost", "ExternalReferenceLibrary", "ExternalReferenceQueue",
|
||||
"GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword"
|
||||
"GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword",
|
||||
# Marketing / Ad Engine
|
||||
"Campaign", "Creative", "Placement", "CampaignCreative",
|
||||
"CampaignPlacement", "AdImpression", "AdClick",
|
||||
"CampaignStatus", "CreativeType", "PlacementType",
|
||||
]
|
||||
@@ -23,8 +23,8 @@ class SubscriptionTier(Base):
|
||||
is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
class OrganizationSubscription(Base):
|
||||
"""
|
||||
Szervezetek aktuális előfizetései és azok érvényessége.
|
||||
"""
|
||||
Szervezetek aktuális előfizetései és azok érvényessége.
|
||||
"""
|
||||
__tablename__ = "org_subscriptions"
|
||||
__table_args__ = {"schema": "finance"}
|
||||
@@ -40,6 +40,11 @@ class OrganizationSubscription(Base):
|
||||
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# ── P0 BOOSTER ARCHITECTURE: extra_allowances JSONB ──
|
||||
# Tárolja a darabáron vett extra kvótákat a csomag keretein felül.
|
||||
# Példa: {"extra_vehicles": 1, "extra_garages": 1, "extra_credits": 100}
|
||||
extra_allowances: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"), default=dict)
|
||||
|
||||
class UserSubscription(Base):
|
||||
"""
|
||||
|
||||
300
backend/app/models/marketing.py
Normal file
300
backend/app/models/marketing.py
Normal file
@@ -0,0 +1,300 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/marketing.py
|
||||
"""
|
||||
📢 Ad Engine — Marketing Schema Models
|
||||
|
||||
Extensible Native Ad Engine for Service Finder.
|
||||
Supports:
|
||||
- Campaign lifecycle (draft → scheduled → active → paused → completed → cancelled)
|
||||
- Internal (IMAGE) and external (HTML_SNIPPET) creatives
|
||||
- Priority-based + weighted random ad selection with fallback
|
||||
- Impression and click tracking for analytics
|
||||
|
||||
Schema: marketing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy import (
|
||||
String, Integer, Boolean, DateTime, Float, Text,
|
||||
ForeignKey, UniqueConstraint, Index, CheckConstraint, text
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
# ── Enums (Python-side only; stored as String in DB for portability) ───────────
|
||||
|
||||
|
||||
class CampaignStatus(str, enum.Enum):
|
||||
"""Kampány életciklus állapotai."""
|
||||
DRAFT = "draft"
|
||||
SCHEDULED = "scheduled"
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class CreativeType(str, enum.Enum):
|
||||
"""Kreatív típusok (bővíthető)."""
|
||||
IMAGE = "image" # Belső feltöltésű kép
|
||||
HTML_SNIPPET = "html_snippet" # Külső HTML kód (pl. Google Ads)
|
||||
# Jövőbeli: VIDEO, CAROUSEL, TEXT
|
||||
|
||||
|
||||
class PlacementType(str, enum.Enum):
|
||||
"""Hirdetési helyek a felületen."""
|
||||
SIDEBAR = "sidebar"
|
||||
BANNER_TOP = "banner_top"
|
||||
BANNER_BOTTOM = "banner_bottom"
|
||||
MODAL = "modal"
|
||||
INLINE = "inline"
|
||||
DASHBOARD_WIDGET = "dashboard_widget"
|
||||
|
||||
|
||||
# ── Models ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Campaign(Base):
|
||||
"""
|
||||
Hirdetési kampány.
|
||||
|
||||
Egy kampány több kreatívot és több elhelyezést (placement) is
|
||||
tartalmazhat a many-to-many kapcsolaton keresztül.
|
||||
"""
|
||||
__tablename__ = "campaigns"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('draft', 'scheduled', 'active', 'paused', 'completed', 'cancelled')",
|
||||
name="ck_campaign_status"
|
||||
),
|
||||
{"schema": "marketing"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Kampány életciklus (String instead of native PG enum for portability)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default=CampaignStatus.DRAFT.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Célzási beállítások (JSONB a rugalmasságért)
|
||||
# Pl. {"target_tier": ["free", "premium"], "target_country": ["HU", "UK"]}
|
||||
targeting: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True, server_default=text("'{}'::jsonb"))
|
||||
|
||||
# Prioritás (kisebb szám = magasabb prioritás)
|
||||
priority: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
|
||||
|
||||
# Időbeli korlátok
|
||||
start_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
end_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Napi költési limit (impresszióban vagy pénzben)
|
||||
daily_impression_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
total_impression_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Metrikák (denormalizált gyors lekérdezéshez)
|
||||
current_impressions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
current_clicks: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Metaadatok
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
created_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Admin user ID
|
||||
|
||||
# Kapcsolatok
|
||||
creatives: Mapped[List["CampaignCreative"]] = relationship(
|
||||
"CampaignCreative", back_populates="campaign", cascade="all, delete-orphan"
|
||||
)
|
||||
placements: Mapped[List["CampaignPlacement"]] = relationship(
|
||||
"CampaignPlacement", back_populates="campaign", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Creative(Base):
|
||||
"""
|
||||
Kreatív (hirdetés anyag).
|
||||
|
||||
Két fő típus:
|
||||
- IMAGE: belső feltöltésű kép (image_url, alt_text)
|
||||
- HTML_SNIPPET: külső HTML kód (pl. Google Ads vagy más hirdetési hálózat)
|
||||
"""
|
||||
__tablename__ = "creatives"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"creative_type IN ('image', 'html_snippet')",
|
||||
name="ck_creative_type"
|
||||
),
|
||||
{"schema": "marketing"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
|
||||
# Típus (String instead of native PG enum for portability)
|
||||
creative_type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# IMAGE típushoz
|
||||
image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
alt_text: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||
click_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Átkattintási link
|
||||
|
||||
# HTML_SNIPPET típushoz
|
||||
html_snippet: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Közös mezők
|
||||
width: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
height: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
weight: Mapped[float] = mapped_column(Float, default=1.0) # Súly a random kiválasztáshoz
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Kapcsolatok
|
||||
campaigns: Mapped[List["CampaignCreative"]] = relationship(
|
||||
"CampaignCreative", back_populates="creative", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Placement(Base):
|
||||
"""
|
||||
Hirdetési hely (placement) a felületen.
|
||||
|
||||
Meghatározza, hogy a hirdetés hol jelenjen meg a frontenden.
|
||||
"""
|
||||
__tablename__ = "placements"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"placement_type IN ('sidebar', 'banner_top', 'banner_bottom', 'modal', 'inline', 'dashboard_widget')",
|
||||
name="ck_placement_type"
|
||||
),
|
||||
{"schema": "marketing"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
||||
placement_type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Kapcsolatok
|
||||
campaigns: Mapped[List["CampaignPlacement"]] = relationship(
|
||||
"CampaignPlacement", back_populates="placement", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class CampaignCreative(Base):
|
||||
"""
|
||||
Many-to-Many kapcsolótábla: Campaign <-> Creative.
|
||||
|
||||
Lehetővé teszi, hogy egy kampány több kreatívot használjon,
|
||||
és egy kreatív több kampányban is szerepelhessen.
|
||||
"""
|
||||
__tablename__ = "campaign_creatives"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("campaign_id", "creative_id", name="uq_campaign_creative"),
|
||||
{"schema": "marketing"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
|
||||
creative_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.creatives.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# Kapcsolatok
|
||||
campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="creatives")
|
||||
creative: Mapped["Creative"] = relationship("Creative", back_populates="campaigns")
|
||||
|
||||
|
||||
class CampaignPlacement(Base):
|
||||
"""
|
||||
Many-to-Many kapcsolótábla: Campaign <-> Placement.
|
||||
|
||||
Meghatározza, hogy egy kampány mely hirdetési helyeken jelenjen meg.
|
||||
"""
|
||||
__tablename__ = "campaign_placements"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("campaign_id", "placement_id", name="uq_campaign_placement"),
|
||||
{"schema": "marketing"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
|
||||
placement_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.placements.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# Kapcsolatok
|
||||
campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="placements")
|
||||
placement: Mapped["Placement"] = relationship("Placement", back_populates="campaigns")
|
||||
|
||||
|
||||
class AdImpression(Base):
|
||||
"""
|
||||
Hirdetés megjelenítési napló.
|
||||
|
||||
Minden egyes alkalommal rögzíti, amikor egy hirdetés megjelent
|
||||
a felületen. Ez az adat szolgál a kampányok analitikájához.
|
||||
"""
|
||||
__tablename__ = "ad_impressions"
|
||||
__table_args__ = (
|
||||
Index("idx_impression_campaign", "campaign_id"),
|
||||
Index("idx_impression_created", "created_at"),
|
||||
{"schema": "marketing"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
|
||||
creative_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketing.creatives.id", ondelete="SET NULL"), nullable=True)
|
||||
placement_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketing.placements.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# Kontextus
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Bejelentkezett user (ha van)
|
||||
session_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # Anonymous session
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) # IPv4 vagy IPv6
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Időbélyeg
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class AdClick(Base):
|
||||
"""
|
||||
Hirdetés kattintási napló.
|
||||
|
||||
Minden egyes kattintást rögzít, ami egy hirdetésen történt.
|
||||
"""
|
||||
__tablename__ = "ad_clicks"
|
||||
__table_args__ = (
|
||||
Index("idx_click_campaign", "campaign_id"),
|
||||
Index("idx_click_created", "created_at"),
|
||||
{"schema": "marketing"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
impression_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("marketing.ad_impressions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
|
||||
creative_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketing.creatives.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
session_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -6,7 +6,10 @@ A rules struktúra a seed_packages.py-ban definiált formátumot követi:
|
||||
|
||||
{
|
||||
"type": "private" | "corporate",
|
||||
"pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
|
||||
"pricing_zones": {
|
||||
"HU": {"monthly_price": 3000, "yearly_price": 32500, "currency": "HUF"},
|
||||
"DEFAULT": {"monthly_price": 12.99, "yearly_price": 129.99, "currency": "EUR"}
|
||||
},
|
||||
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
|
||||
"entitlements": ["SRV_..."],
|
||||
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
|
||||
@@ -25,8 +28,16 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
# ── Belső (embedded) modellek a rules JSONB struktúrához ──────────────────────
|
||||
|
||||
|
||||
class PricingZoneModel(BaseModel):
|
||||
"""Egyetlen árazási zóna adatai (ország-specifikus ár)."""
|
||||
monthly_price: float = Field(..., ge=0, description="Havi előfizetési díj")
|
||||
yearly_price: float = Field(..., ge=0, description="Éves előfizetési díj")
|
||||
currency: str = Field(default="EUR", min_length=3, max_length=3, description="Pénznem ISO 4217 kódja")
|
||||
credit_price: Optional[int] = Field(default=None, ge=0, description="Kreditár (havi kredit költség a csomaghoz)")
|
||||
|
||||
|
||||
class PricingModel(BaseModel):
|
||||
"""Árazási információk egy előfizetési csomaghoz."""
|
||||
"""Árazási információk egy előfizetési csomaghoz (legacy, egyzónás)."""
|
||||
monthly_price: float = Field(..., ge=0, description="Havi előfizetési díj (EUR)")
|
||||
yearly_price: float = Field(..., ge=0, description="Éves előfizetési díj (EUR)")
|
||||
currency: str = Field(default="EUR", min_length=3, max_length=3, description="Pénznem ISO 4217 kódja")
|
||||
@@ -40,6 +51,19 @@ class AllowancesModel(BaseModel):
|
||||
monthly_free_credits: int = Field(..., ge=0, description="Havi ingyenes kreditek száma")
|
||||
|
||||
|
||||
class DurationModel(BaseModel):
|
||||
"""Előfizetés időtartamának beállításai (duration_days stacking)."""
|
||||
days: int = Field(default=30, ge=1, description="Előfizetés időtartama napokban (pl. 30 = havi, 365 = éves)")
|
||||
allow_stacking: bool = Field(default=True, description="Időhalmozás engedélyezése (stacking): ha True, a meglévő valid_until-hoz hozzáadódik")
|
||||
|
||||
|
||||
class AdPolicyModel(BaseModel):
|
||||
"""Hirdetési szabályok a csomaghoz."""
|
||||
show_ads: bool = Field(default=True, description="Megjelenjenek-e hirdetések a felhasználónak")
|
||||
ad_free_grace_days: int = Field(default=0, ge=0, description="Hány nap türelmi idő a reklámok eltűnéséig fizetés után")
|
||||
max_daily_impressions: Optional[int] = Field(default=None, ge=0, description="Maximális napi hirdetésmegjelenítés (None = korlátlan)")
|
||||
|
||||
|
||||
class LifecycleModel(BaseModel):
|
||||
"""Életciklus állapotok egy előfizetési csomaghoz."""
|
||||
is_public: bool = Field(default=True, description="Publikus-e a csomag (látszik-e a felhasználóknak)")
|
||||
@@ -52,6 +76,13 @@ class AffiliateModel(BaseModel):
|
||||
referral_bonus_credits: int = Field(..., ge=0, description="Ajánlási bónusz kreditek száma")
|
||||
|
||||
|
||||
class MarketingModel(BaseModel):
|
||||
"""Marketing információk egy előfizetési csomaghoz (kártya megjelenítéshez)."""
|
||||
subtitle: Optional[str] = Field(default=None, description="Rövid leíró szöveg a kártyán (pl. 'Kisebb flotta esetén.')")
|
||||
badge: Optional[str] = Field(default=None, description="Jelvény szöveg (pl. 'Legnépszerűbb', 'Pro')")
|
||||
highlight_color: Optional[str] = Field(default=None, description="Kiemelő szín hex kódja (pl. '#70BC84')")
|
||||
|
||||
|
||||
class SubscriptionRulesModel(BaseModel):
|
||||
"""
|
||||
A subscription_tiers.rules JSONB oszlopának erősen tipizált modellje.
|
||||
@@ -59,11 +90,15 @@ class SubscriptionRulesModel(BaseModel):
|
||||
Fields:
|
||||
type: "private" (magánszemély) vagy "corporate" (vállalati)
|
||||
display_name: Emberi olvasásra szánt megjelenítési név (pl. "Privát Ingyenes")
|
||||
pricing: Árazási adatok (havi/éves díj, pénznem)
|
||||
pricing: Árazási adatok (legacy, egyzónás) - deprecated, használd a pricing_zones-t
|
||||
pricing_zones: Több régiós árazási adatok (országkód -> PricingZoneModel)
|
||||
allowances: Korlátok (max jármű, garázs, kredit)
|
||||
entitlements: Jogosultságok listája (pl. SRV_DATA_EXPORT)
|
||||
affiliate: Partnerprogram beállítások
|
||||
lifecycle: Életciklus állapotok (opcionális, pl. is_public)
|
||||
duration: Előfizetés időtartamának beállításai (duration_days, stacking)
|
||||
ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days)
|
||||
marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)
|
||||
"""
|
||||
type: str = Field(..., pattern=r"^(private|corporate)$", description="Csomag típusa")
|
||||
display_name: Optional[str] = Field(
|
||||
@@ -72,7 +107,11 @@ class SubscriptionRulesModel(BaseModel):
|
||||
)
|
||||
pricing: Optional[PricingModel] = Field(
|
||||
default=None,
|
||||
description="Árazási adatok (havi/éves díj, pénznem). Opcionális a hiányos seed adatok miatt."
|
||||
description="Árazási adatok (legacy, egyzónás). Deprecated: használd a pricing_zones-t.",
|
||||
)
|
||||
pricing_zones: Optional[Dict[str, PricingZoneModel]] = Field(
|
||||
default=None,
|
||||
description="Több régiós árazás. Kulcs: országkód (pl. 'HU', 'DE', 'DEFAULT').",
|
||||
)
|
||||
allowances: Optional[AllowancesModel] = Field(
|
||||
default=None,
|
||||
@@ -87,6 +126,18 @@ class SubscriptionRulesModel(BaseModel):
|
||||
default=None,
|
||||
description="Életciklus állapotok (is_public, available_until)"
|
||||
)
|
||||
duration: Optional[DurationModel] = Field(
|
||||
default=None,
|
||||
description="Előfizetés időtartamának beállításai (duration_days, allow_stacking)"
|
||||
)
|
||||
ad_policy: Optional[AdPolicyModel] = Field(
|
||||
default=None,
|
||||
description="Hirdetési szabályok (show_ads, ad_free_grace_days, max_daily_impressions)"
|
||||
)
|
||||
marketing: Optional[MarketingModel] = Field(
|
||||
default=None,
|
||||
description="Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)"
|
||||
)
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
@@ -109,6 +160,23 @@ class SubscriptionTierResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PublicSubscriptionTierResponse(BaseModel):
|
||||
"""
|
||||
Publikus előfizetési csomag válasz a frontend számára.
|
||||
Tartalmazza a feloldott (resolved) árazást a felhasználó régiója alapján.
|
||||
"""
|
||||
id: int
|
||||
name: str
|
||||
rules: SubscriptionRulesModel
|
||||
is_custom: bool
|
||||
resolved_pricing: Optional[PricingZoneModel] = Field(
|
||||
default=None,
|
||||
description="A felhasználó régiójára feloldott árazás (egyetlen PricingZoneModel).",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SubscriptionTierCreate(BaseModel):
|
||||
"""Új előfizetési csomag létrehozása (POST /)."""
|
||||
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
|
||||
|
||||
@@ -10,7 +10,10 @@ Architektúra:
|
||||
A csomagok rules JSONB oszlopa egy szabványos struktúrát követ:
|
||||
{
|
||||
"type": "private" | "corporate",
|
||||
"pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
|
||||
"pricing_zones": {
|
||||
"HU": {"monthly_price": 3000, "yearly_price": 32500, "currency": "HUF"},
|
||||
"DEFAULT": {"monthly_price": 12.99, "yearly_price": 129.99, "currency": "EUR"}
|
||||
},
|
||||
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
|
||||
"entitlements": ["SRV_..."],
|
||||
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
|
||||
@@ -31,6 +34,46 @@ logging.basicConfig(
|
||||
logger = logging.getLogger("Seed-Packages")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pricing Zones helper
|
||||
# ---------------------------------------------------------------------------
|
||||
def pricing_zones(
|
||||
eur_monthly: float,
|
||||
eur_yearly: float,
|
||||
eur_credit: int = 0,
|
||||
huf_monthly: int = 0,
|
||||
huf_yearly: int = 0,
|
||||
huf_credit: int = 0,
|
||||
usd_monthly: float = 0,
|
||||
usd_yearly: float = 0,
|
||||
usd_credit: int = 0,
|
||||
) -> dict:
|
||||
"""Create a pricing_zones dict with DEFAULT (EUR), HU (HUF), and optionally USD zones."""
|
||||
zones = {
|
||||
"DEFAULT": {
|
||||
"monthly_price": eur_monthly,
|
||||
"yearly_price": eur_yearly,
|
||||
"currency": "EUR",
|
||||
"credit_price": eur_credit if eur_credit > 0 else None,
|
||||
},
|
||||
}
|
||||
if huf_monthly > 0:
|
||||
zones["HU"] = {
|
||||
"monthly_price": huf_monthly,
|
||||
"yearly_price": huf_yearly,
|
||||
"currency": "HUF",
|
||||
"credit_price": huf_credit if huf_credit > 0 else None,
|
||||
}
|
||||
if usd_monthly > 0:
|
||||
zones["US"] = {
|
||||
"monthly_price": usd_monthly,
|
||||
"yearly_price": usd_yearly,
|
||||
"currency": "USD",
|
||||
"credit_price": usd_credit if usd_credit > 0 else None,
|
||||
}
|
||||
return zones
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Csomagdefiníciók
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -46,6 +89,12 @@ PACKAGES = [
|
||||
"currency": "EUR",
|
||||
"credit_price": 0,
|
||||
},
|
||||
"pricing_zones": pricing_zones(
|
||||
eur_monthly=0.0,
|
||||
eur_yearly=0.0,
|
||||
huf_monthly=0,
|
||||
huf_yearly=0,
|
||||
),
|
||||
"allowances": {
|
||||
"max_vehicles": 1,
|
||||
"max_garages": 1,
|
||||
@@ -59,6 +108,19 @@ PACKAGES = [
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
"duration": {
|
||||
"days": 30,
|
||||
"allow_stacking": True,
|
||||
},
|
||||
"ad_policy": {
|
||||
"show_ads": True,
|
||||
"ad_free_grace_days": 0,
|
||||
"max_daily_impressions": 50,
|
||||
},
|
||||
"marketing": {
|
||||
"subtitle": "Alap szintű járműkövetés kezdőknek.",
|
||||
"badge": "Ingyenes",
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
@@ -73,6 +135,17 @@ PACKAGES = [
|
||||
"currency": "EUR",
|
||||
"credit_price": 500,
|
||||
},
|
||||
"pricing_zones": pricing_zones(
|
||||
eur_monthly=4.99,
|
||||
eur_yearly=49.99,
|
||||
eur_credit=500,
|
||||
huf_monthly=1890,
|
||||
huf_yearly=18990,
|
||||
huf_credit=500,
|
||||
usd_monthly=5.99,
|
||||
usd_yearly=59.99,
|
||||
usd_credit=500,
|
||||
),
|
||||
"allowances": {
|
||||
"max_vehicles": 3,
|
||||
"max_garages": 1,
|
||||
@@ -86,6 +159,20 @@ PACKAGES = [
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
"duration": {
|
||||
"days": 30,
|
||||
"allow_stacking": True,
|
||||
},
|
||||
"ad_policy": {
|
||||
"show_ads": True,
|
||||
"ad_free_grace_days": 3,
|
||||
"max_daily_impressions": 100,
|
||||
},
|
||||
"marketing": {
|
||||
"subtitle": "Kisebb flotta esetén ideális választás.",
|
||||
"badge": "Pro",
|
||||
"highlight_color": "#70BC84",
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
@@ -100,6 +187,17 @@ PACKAGES = [
|
||||
"currency": "EUR",
|
||||
"credit_price": 1200,
|
||||
},
|
||||
"pricing_zones": pricing_zones(
|
||||
eur_monthly=9.99,
|
||||
eur_yearly=99.99,
|
||||
eur_credit=1200,
|
||||
huf_monthly=3790,
|
||||
huf_yearly=37990,
|
||||
huf_credit=1200,
|
||||
usd_monthly=11.99,
|
||||
usd_yearly=119.99,
|
||||
usd_credit=1200,
|
||||
),
|
||||
"allowances": {
|
||||
"max_vehicles": 10,
|
||||
"max_garages": 1,
|
||||
@@ -113,6 +211,20 @@ PACKAGES = [
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
"duration": {
|
||||
"days": 30,
|
||||
"allow_stacking": True,
|
||||
},
|
||||
"ad_policy": {
|
||||
"show_ads": False,
|
||||
"ad_free_grace_days": 0,
|
||||
"max_daily_impressions": None,
|
||||
},
|
||||
"marketing": {
|
||||
"subtitle": "VIP profi garázs kisvállalkozók számára.",
|
||||
"badge": "VIP",
|
||||
"highlight_color": "#F59E0B",
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
@@ -127,6 +239,17 @@ PACKAGES = [
|
||||
"currency": "EUR",
|
||||
"credit_price": 3000,
|
||||
},
|
||||
"pricing_zones": pricing_zones(
|
||||
eur_monthly=29.99,
|
||||
eur_yearly=299.99,
|
||||
eur_credit=3000,
|
||||
huf_monthly=11990,
|
||||
huf_yearly=119990,
|
||||
huf_credit=3000,
|
||||
usd_monthly=34.99,
|
||||
usd_yearly=349.99,
|
||||
usd_credit=3000,
|
||||
),
|
||||
"allowances": {
|
||||
"max_vehicles": 20,
|
||||
"max_garages": 3,
|
||||
@@ -140,6 +263,20 @@ PACKAGES = [
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
"duration": {
|
||||
"days": 30,
|
||||
"allow_stacking": True,
|
||||
},
|
||||
"ad_policy": {
|
||||
"show_ads": False,
|
||||
"ad_free_grace_days": 0,
|
||||
"max_daily_impressions": None,
|
||||
},
|
||||
"marketing": {
|
||||
"subtitle": "Közepes flották számára tervezve.",
|
||||
"badge": "Prémium",
|
||||
"highlight_color": "#3B82F6",
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
@@ -154,6 +291,17 @@ PACKAGES = [
|
||||
"currency": "EUR",
|
||||
"credit_price": 6000,
|
||||
},
|
||||
"pricing_zones": pricing_zones(
|
||||
eur_monthly=59.99,
|
||||
eur_yearly=599.99,
|
||||
eur_credit=6000,
|
||||
huf_monthly=23990,
|
||||
huf_yearly=239990,
|
||||
huf_credit=6000,
|
||||
usd_monthly=69.99,
|
||||
usd_yearly=699.99,
|
||||
usd_credit=6000,
|
||||
),
|
||||
"allowances": {
|
||||
"max_vehicles": 50,
|
||||
"max_garages": 10,
|
||||
@@ -171,6 +319,20 @@ PACKAGES = [
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
"duration": {
|
||||
"days": 30,
|
||||
"allow_stacking": True,
|
||||
},
|
||||
"ad_policy": {
|
||||
"show_ads": False,
|
||||
"ad_free_grace_days": 0,
|
||||
"max_daily_impressions": None,
|
||||
},
|
||||
"marketing": {
|
||||
"subtitle": "Nagy flották prémium menedzsmentje.",
|
||||
"badge": "Plus",
|
||||
"highlight_color": "#8B5CF6",
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
@@ -185,6 +347,17 @@ PACKAGES = [
|
||||
"currency": "EUR",
|
||||
"credit_price": 15000,
|
||||
},
|
||||
"pricing_zones": pricing_zones(
|
||||
eur_monthly=149.99,
|
||||
eur_yearly=1499.99,
|
||||
eur_credit=15000,
|
||||
huf_monthly=59990,
|
||||
huf_yearly=599990,
|
||||
huf_credit=15000,
|
||||
usd_monthly=169.99,
|
||||
usd_yearly=1699.99,
|
||||
usd_credit=15000,
|
||||
),
|
||||
"allowances": {
|
||||
"max_vehicles": 200,
|
||||
"max_garages": 50,
|
||||
@@ -203,6 +376,20 @@ PACKAGES = [
|
||||
"lifecycle": {
|
||||
"is_public": True,
|
||||
},
|
||||
"duration": {
|
||||
"days": 30,
|
||||
"allow_stacking": True,
|
||||
},
|
||||
"ad_policy": {
|
||||
"show_ads": False,
|
||||
"ad_free_grace_days": 0,
|
||||
"max_daily_impressions": None,
|
||||
},
|
||||
"marketing": {
|
||||
"subtitle": "Vállalati szintű flottairányítás.",
|
||||
"badge": "VIP",
|
||||
"highlight_color": "#EF4444",
|
||||
},
|
||||
},
|
||||
"is_custom": False,
|
||||
},
|
||||
@@ -215,28 +402,31 @@ async def seed():
|
||||
logger.info("=" * 60)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 1. Töröljük a meglévő rekordokat (tiszta lap)
|
||||
logger.info("Törlöm a meglévő subscription_tiers rekordokat...")
|
||||
await db.execute(delete(SubscriptionTier))
|
||||
await db.commit()
|
||||
logger.info("✅ Meglévő rekordok törölve.")
|
||||
# 1. UPSERT: Meglévő csomagok frissítése vagy új beszúrása
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
# 2. Beszúrjuk a 6 új csomagot
|
||||
inserted_count = 0
|
||||
upserted_count = 0
|
||||
for pkg in PACKAGES:
|
||||
tier = SubscriptionTier(
|
||||
stmt = pg_insert(SubscriptionTier).values(
|
||||
name=pkg["name"],
|
||||
rules=pkg["rules"],
|
||||
is_custom=pkg["is_custom"],
|
||||
)
|
||||
db.add(tier)
|
||||
inserted_count += 1
|
||||
logger.info(f" ➕ Hozzáadva: {pkg['name']}")
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[SubscriptionTier.name],
|
||||
set_={
|
||||
"rules": stmt.excluded.rules,
|
||||
"is_custom": stmt.excluded.is_custom,
|
||||
}
|
||||
)
|
||||
await db.execute(stmt)
|
||||
upserted_count += 1
|
||||
logger.info(f" ➕ Upsert: {pkg['name']}")
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ {inserted_count} csomag sikeresen beszúrva.")
|
||||
logger.info(f"✅ {upserted_count} csomag sikeresen upsertelve.")
|
||||
|
||||
# 3. Visszaigazolás: listázzuk ki az összes rekordot
|
||||
# 2. Visszaigazolás
|
||||
logger.info("-" * 60)
|
||||
logger.info("Visszaigazolás - system.subscription_tiers tartalma:")
|
||||
logger.info("-" * 60)
|
||||
@@ -256,7 +446,7 @@ async def seed():
|
||||
|
||||
logger.info(f"\n✅ Összesen {len(tiers)} aktív csomag a rendszerben.")
|
||||
|
||||
# 4. JSONB példa kiírása
|
||||
# 3. JSONB példa kiírása
|
||||
if tiers:
|
||||
sample = tiers[0]
|
||||
logger.info("=" * 60)
|
||||
|
||||
@@ -494,9 +494,11 @@ class AssetService:
|
||||
Get the vehicle limit for a user, checking:
|
||||
1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
|
||||
2. Organization-level base_asset_limit (fallback if no user subscription)
|
||||
3. Config-based limits (legacy fallback)
|
||||
3. P0 BOOSTER: extra_allowances from finance.org_subscriptions (additive on top of tier)
|
||||
4. Config-based limits (legacy fallback)
|
||||
|
||||
P0: The subscription_tier JSONB rules are now the Single Source of Truth.
|
||||
P0 Booster: extra_allowances.extra_vehicles is added on top of the tier limit.
|
||||
Legacy string-based subscription_plan lookups have been removed.
|
||||
|
||||
Args:
|
||||
@@ -508,7 +510,7 @@ class AssetService:
|
||||
Maximum allowed vehicles
|
||||
"""
|
||||
from app.models.identity import User
|
||||
from app.models.core_logic import UserSubscription, SubscriptionTier
|
||||
from app.models.core_logic import UserSubscription, SubscriptionTier, OrganizationSubscription
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.services.config_service import config
|
||||
|
||||
@@ -577,7 +579,33 @@ class AssetService:
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read org subscription tier limit for org {org_id}: {e}")
|
||||
|
||||
# ── 3. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
|
||||
# ── 3. P0 BOOSTER: extra_allowances from finance.org_subscriptions ──
|
||||
# Extra allowances are additive on top of the tier limit.
|
||||
# Read from the active org subscription's extra_allowances JSONB.
|
||||
extra_vehicles = 0
|
||||
try:
|
||||
org_sub_stmt = (
|
||||
select(OrganizationSubscription.extra_allowances)
|
||||
.where(
|
||||
OrganizationSubscription.org_id == org_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))
|
||||
if extra_vehicles > 0:
|
||||
logger.info(
|
||||
f"[P0 BOOSTER] Extra vehicles for org {org_id}: "
|
||||
f"extra_vehicles={extra_vehicles} (from extra_allowances JSONB)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read extra_allowances for org {org_id}: {e}")
|
||||
|
||||
# ── 4. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
|
||||
config_limit = None
|
||||
try:
|
||||
limits = await config.get_setting(db, "VEHICLE_LIMIT")
|
||||
@@ -591,16 +619,20 @@ class AssetService:
|
||||
if config_limit is None:
|
||||
config_limit = 1 # absolute fallback
|
||||
|
||||
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
|
||||
# ── 5. FINAL: Take the HIGHEST of all applicable limits, then ADD boosters ──
|
||||
final_limit = config_limit
|
||||
if org_limit is not None:
|
||||
final_limit = max(final_limit, org_limit)
|
||||
if subscription_limit is not None:
|
||||
final_limit = max(final_limit, subscription_limit)
|
||||
|
||||
# Add extra_allowances on top of the base limit
|
||||
final_limit = final_limit + extra_vehicles
|
||||
|
||||
logger.info(
|
||||
f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
|
||||
f"subscription_tier={subscription_limit}, org={org_limit}, config={config_limit}, "
|
||||
f"subscription_tier={subscription_limit}, org={org_limit}, "
|
||||
f"extra_vehicles={extra_vehicles}, config={config_limit}, "
|
||||
f"final={final_limit}"
|
||||
)
|
||||
|
||||
|
||||
@@ -727,6 +727,9 @@ async def upgrade_subscription(
|
||||
"""
|
||||
Felhasználó előfizetésének frissítése (csomagváltás).
|
||||
|
||||
P0 Stacking Feature: A duration_days mezőt a tier.rules-ból olvassa ki,
|
||||
és a valid_until számításánál időhalmozást (stacking) alkalmaz.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Felhasználó ID
|
||||
@@ -735,7 +738,7 @@ async def upgrade_subscription(
|
||||
Returns:
|
||||
Dict: Tranzakció részletei és az új előfizetés információi
|
||||
"""
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
from app.models.core_logic import SubscriptionTier, UserSubscription
|
||||
from app.models.identity import User
|
||||
|
||||
# 1. Ellenőrizze, hogy a cél csomag létezik-e
|
||||
@@ -746,23 +749,67 @@ async def upgrade_subscription(
|
||||
if not tier:
|
||||
raise ValueError(f"Subscription tier '{target_package}' not found")
|
||||
|
||||
# ── P0 STACKING: duration_days kiolvasása a tier.rules-ból ──
|
||||
duration_days = 30 # alapértelmezett 30 nap
|
||||
allow_stacking = True
|
||||
if tier.rules:
|
||||
duration_config = tier.rules.get("duration", {})
|
||||
if isinstance(duration_config, dict):
|
||||
duration_days = duration_config.get("days", 30)
|
||||
allow_stacking = duration_config.get("allow_stacking", True)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# 2. Számítsa ki az árát a csomagnak (egyszerűsítve: fix ár a tier.rules-ból)
|
||||
price = tier.rules.get("price", 0.0) if tier.rules else 0.0
|
||||
if price <= 0:
|
||||
# Ingyenes csomag, nincs levonás
|
||||
logger.info(f"Upgrading user {user_id} to free tier {target_package}")
|
||||
|
||||
# Frissítse a felhasználó subscription_plan mezőjét
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one()
|
||||
user.subscription_plan = target_package
|
||||
user.subscription_expires_at = datetime.utcnow() + timedelta(days=30) # 30 nap
|
||||
|
||||
# ── P0 STACKING: user_subscriptions tábla kezelése ──
|
||||
# Lekérdezzük a meglévő aktív user subscription-t
|
||||
us_stmt = select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True
|
||||
)
|
||||
us_result = await db.execute(us_stmt)
|
||||
existing_us = us_result.scalar_one_or_none()
|
||||
|
||||
if existing_us:
|
||||
existing_us.is_active = False
|
||||
|
||||
# Stacking számítás
|
||||
if existing_us and allow_stacking and existing_us.valid_until and existing_us.valid_until > now:
|
||||
new_valid_until = existing_us.valid_until + timedelta(days=duration_days)
|
||||
else:
|
||||
new_valid_until = now + timedelta(days=duration_days)
|
||||
|
||||
# Új UserSubscription rekord
|
||||
new_us = UserSubscription(
|
||||
user_id=user_id,
|
||||
tier_id=tier.id,
|
||||
valid_from=now,
|
||||
valid_until=new_valid_until,
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_us)
|
||||
|
||||
user.subscription_expires_at = new_valid_until
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Upgraded to {target_package} (free)",
|
||||
"new_plan": target_package,
|
||||
"price_paid": 0.0
|
||||
"price_paid": 0.0,
|
||||
"valid_until": new_valid_until.isoformat(),
|
||||
"duration_days": duration_days,
|
||||
"stacking_applied": allow_stacking and bool(existing_us and existing_us.valid_until and existing_us.valid_until > now)
|
||||
}
|
||||
|
||||
# 3. Ár kiszámítása a PricingCalculator segítségével
|
||||
@@ -789,15 +836,51 @@ async def upgrade_subscription(
|
||||
|
||||
# 5. Frissítse a felhasználó előfizetési adatait
|
||||
user.subscription_plan = target_package
|
||||
user.subscription_expires_at = datetime.utcnow() + timedelta(days=30) # 30 nap
|
||||
|
||||
logger.info(f"User {user_id} upgraded to {target_package} for {final_price} EUR")
|
||||
# ── P0 STACKING: user_subscriptions tábla kezelése ──
|
||||
us_stmt = select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True
|
||||
)
|
||||
us_result = await db.execute(us_stmt)
|
||||
existing_us = us_result.scalar_one_or_none()
|
||||
|
||||
if existing_us:
|
||||
existing_us.is_active = False
|
||||
|
||||
# Stacking számítás
|
||||
if existing_us and allow_stacking and existing_us.valid_until and existing_us.valid_until > now:
|
||||
new_valid_until = existing_us.valid_until + timedelta(days=duration_days)
|
||||
else:
|
||||
new_valid_until = now + timedelta(days=duration_days)
|
||||
|
||||
# Új UserSubscription rekord
|
||||
new_us = UserSubscription(
|
||||
user_id=user_id,
|
||||
tier_id=tier.id,
|
||||
valid_from=now,
|
||||
valid_until=new_valid_until,
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_us)
|
||||
|
||||
user.subscription_expires_at = new_valid_until
|
||||
|
||||
logger.info(
|
||||
f"User {user_id} upgraded to {target_package} for {final_price} EUR, "
|
||||
f"valid_until={new_valid_until.isoformat()}, "
|
||||
f"duration_days={duration_days}, "
|
||||
f"stacking={allow_stacking}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"transaction": transaction,
|
||||
"new_plan": target_package,
|
||||
"price_paid": final_price,
|
||||
"valid_until": new_valid_until.isoformat(),
|
||||
"duration_days": duration_days,
|
||||
"stacking_applied": allow_stacking and bool(existing_us and existing_us.valid_until and existing_us.valid_until > now),
|
||||
"expires_at": user.subscription_expires_at.isoformat()
|
||||
}
|
||||
|
||||
@@ -816,6 +899,12 @@ async def upgrade_org_subscription(
|
||||
subscription_tier_id FK on the Organization record, and also creates/
|
||||
updates a record in finance.org_subscriptions for audit trail.
|
||||
|
||||
P0 Stacking Feature: A duration_days mezőt a tier.rules-ból olvassa ki,
|
||||
és a valid_until számításánál időhalmozást (stacking) alkalmaz:
|
||||
- Ha a meglévő előfizetés még aktív (valid_until > NOW):
|
||||
új valid_until = régi valid_until + duration_days
|
||||
- Ha lejárt vagy nincs: új valid_until = NOW + duration_days
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
org_id: Organization ID
|
||||
@@ -860,24 +949,54 @@ async def upgrade_org_subscription(
|
||||
sub_result = await db.execute(sub_stmt)
|
||||
existing_sub = sub_result.scalar_one_or_none()
|
||||
|
||||
# ── P0 STACKING: duration_days kiolvasása a tier.rules-ból ──
|
||||
duration_days = 30 # alapértelmezett 30 nap
|
||||
allow_stacking = True
|
||||
if tier.rules:
|
||||
duration_config = tier.rules.get("duration", {})
|
||||
if isinstance(duration_config, dict):
|
||||
duration_days = duration_config.get("days", 30)
|
||||
allow_stacking = duration_config.get("allow_stacking", True)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if existing_sub:
|
||||
# Deaktiváljuk a régit
|
||||
existing_sub.is_active = False
|
||||
existing_sub.valid_until = datetime.utcnow()
|
||||
existing_sub.valid_until = now
|
||||
|
||||
# Új aktív subscription rekord
|
||||
# ── P0 STACKING: valid_until számítás időhalmozással ──
|
||||
if existing_sub and allow_stacking and existing_sub.valid_until and existing_sub.valid_until > now:
|
||||
# Stacking: a meglévő valid_until-hoz hozzáadjuk a duration_days-t
|
||||
new_valid_until = existing_sub.valid_until + timedelta(days=duration_days)
|
||||
logger.info(
|
||||
f"STACKING: org_id={org_id} existing valid_until={existing_sub.valid_until} "
|
||||
f"+ {duration_days} days = {new_valid_until}"
|
||||
)
|
||||
else:
|
||||
# Nincs stacking: mostól számítva + duration_days
|
||||
new_valid_until = now + timedelta(days=duration_days)
|
||||
|
||||
# Új aktív subscription rekord stackinggel számolt valid_until-lal
|
||||
new_sub = OrganizationSubscription(
|
||||
org_id=org_id,
|
||||
tier_id=tier_id,
|
||||
valid_from=datetime.utcnow(),
|
||||
valid_from=now,
|
||||
valid_until=new_valid_until,
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
# Frissítsük a szervezet denormalizált mezőit is
|
||||
org.subscription_expires_at = new_valid_until
|
||||
|
||||
logger.info(
|
||||
f"Org subscription upgraded: org_id={org_id}, "
|
||||
f"tier={tier.name} (id={tier_id}), "
|
||||
f"max_vehicles={max_vehicles}, "
|
||||
f"valid_until={new_valid_until.isoformat()}, "
|
||||
f"duration_days={duration_days}, "
|
||||
f"stacking={allow_stacking}, "
|
||||
f"actor={actor_user_id}"
|
||||
)
|
||||
|
||||
@@ -887,6 +1006,9 @@ async def upgrade_org_subscription(
|
||||
"tier_id": tier_id,
|
||||
"tier_name": tier.name,
|
||||
"max_vehicles": max_vehicles,
|
||||
"valid_until": new_valid_until.isoformat(),
|
||||
"duration_days": duration_days,
|
||||
"stacking_applied": allow_stacking and bool(existing_sub and existing_sub.valid_until and existing_sub.valid_until > now),
|
||||
"message": f"Organization {org_id} upgraded to {tier.name}"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user