admin felület fejlesztése garázs, előfizeztési csomagok
This commit is contained in:
@@ -478,22 +478,19 @@ async def quick_add_provider(
|
||||
"""
|
||||
Gyors szolgáltató felvétel crowdsourcingból.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
|
||||
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
|
||||
P0 HYBRID VENDOR REFACTOR (2026-06-25):
|
||||
========================================
|
||||
A quick_add_provider() MOST MÁR ServiceProvider-t hoz létre, NEM Organization-t.
|
||||
Ez a P0 Architecture Refactor (Option C - Hybrid) része.
|
||||
|
||||
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
|
||||
- Ha category_id nincs megadva, a primary_category kihagyásra kerül.
|
||||
- A 4-level kategória rendszerből érkező category_ids és new_tags
|
||||
feldolgozásra kerülnek a ServiceExpertise kapcsolatok létrehozásához.
|
||||
|
||||
Folyamat:
|
||||
Új folyamat:
|
||||
1. (Opcionális) Elsődleges kategória ellenőrzése (expertise_tags)
|
||||
2. Organization létrehozása (is_verified=False, org_type='service_provider')
|
||||
3. ServiceProfile létrehozása
|
||||
2. ServiceProvider létrehozása (marketplace.service_providers)
|
||||
3. ServiceProfile létrehozása (service_provider_id hivatkozással)
|
||||
4. ServiceExpertise kapcsolatok létrehozása (category_id + category_ids + new_tags)
|
||||
5. Branch létrehozása a címmel
|
||||
6. OrganizationMember létrehozása a felhasználóhoz
|
||||
5. 🎮 Gamification pontozás
|
||||
|
||||
NINCS: Organization, Branch, OrganizationMember létrehozás!
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
@@ -501,7 +498,7 @@ async def quick_add_provider(
|
||||
user_id: A felhasználó ID-ja
|
||||
|
||||
Returns:
|
||||
ProviderQuickAddResponse
|
||||
ProviderQuickAddResponse (id = ServiceProvider.id)
|
||||
|
||||
Raises:
|
||||
ValueError: Ha a kategória nem létezik
|
||||
@@ -513,47 +510,39 @@ async def quick_add_provider(
|
||||
if not category:
|
||||
raise ValueError(f"Category with id={data.category_id} not found")
|
||||
primary_category_key = category.key
|
||||
else:
|
||||
category = None
|
||||
|
||||
# 2. Organization létrehozása
|
||||
folder_slug = hashlib.md5(
|
||||
f"{data.name}-{uuid.uuid4()}".encode()
|
||||
).hexdigest()[:12]
|
||||
|
||||
# P1 CRITICAL ALIGN: address_street_name, address_street_type, address_house_number
|
||||
# használata a régi street helyett.
|
||||
org = Organization(
|
||||
name=data.name[:100],
|
||||
full_name=data.name,
|
||||
display_name=data.name[:50],
|
||||
folder_slug=folder_slug,
|
||||
org_type=OrgType.service_provider,
|
||||
is_verified=False,
|
||||
status="pending_verification",
|
||||
subscription_plan="FREE",
|
||||
base_asset_limit=1,
|
||||
purchased_extra_slots=0,
|
||||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||||
external_integration_config={"source": "crowdsourced"},
|
||||
is_ownership_transferable=True,
|
||||
address_city=data.city,
|
||||
# 2. ServiceProvider létrehozása (NEM Organization!)
|
||||
provider = ServiceProvider(
|
||||
name=data.name,
|
||||
address=", ".join(filter(None, [
|
||||
data.address_street_name or "",
|
||||
data.address_street_type or "",
|
||||
data.address_house_number or "",
|
||||
])) or data.name,
|
||||
category=primary_category_key,
|
||||
# Atomizált címmezők
|
||||
city=data.city,
|
||||
address_zip=data.address_zip,
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
plus_code=data.plus_code,
|
||||
owner_id=None,
|
||||
first_registered_at=datetime.now(timezone.utc),
|
||||
current_lifecycle_started_at=datetime.now(timezone.utc),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
# Kapcsolatfelvételi adatok
|
||||
contact_phone=data.contact_phone,
|
||||
contact_email=data.contact_email,
|
||||
website=data.website,
|
||||
# Státusz és forrás
|
||||
status="pending",
|
||||
source="manual",
|
||||
validation_score=50,
|
||||
added_by_user_id=user_id,
|
||||
)
|
||||
db.add(org)
|
||||
db.add(provider)
|
||||
await db.flush()
|
||||
|
||||
# 3. ServiceProfile létrehozása
|
||||
# 3. ServiceProfile létrehozása (service_provider_id hivatkozással)
|
||||
fingerprint = hashlib.sha256(
|
||||
f"quick-add-{org.id}-{datetime.now().isoformat()}".encode()
|
||||
f"quick-add-{provider.id}-{datetime.now().isoformat()}".encode()
|
||||
).hexdigest()[:64]
|
||||
|
||||
# specialization_tags összeállítása: primary_category + user-supplied tags
|
||||
@@ -564,7 +553,7 @@ async def quick_add_provider(
|
||||
spec_tags["user_tags"] = data.tags
|
||||
|
||||
profile = ServiceProfile(
|
||||
organization_id=org.id,
|
||||
service_provider_id=provider.id,
|
||||
fingerprint=fingerprint,
|
||||
status="ghost",
|
||||
location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326),
|
||||
@@ -579,7 +568,6 @@ async def quick_add_provider(
|
||||
# =====================================================================
|
||||
# 4. 🏗️ ServiceExpertise kapcsolatok létrehozása (4-LEVEL CATEGORY)
|
||||
# =====================================================================
|
||||
# Összegyűjtjük az összes kategória ID-t a ServiceExpertise kapcsolatokhoz.
|
||||
all_category_ids: List[int] = []
|
||||
|
||||
# 4a. Elsődleges kategória (ha meg van adva)
|
||||
@@ -605,13 +593,12 @@ async def quick_add_provider(
|
||||
|
||||
# 4d. ServiceExpertise kapcsolatok létrehozása
|
||||
if all_category_ids:
|
||||
# Deduplikáció: csak egyedi ID-k
|
||||
unique_ids = list(set(all_category_ids))
|
||||
for expertise_id in unique_ids:
|
||||
expertise = ServiceExpertise(
|
||||
service_id=profile.id,
|
||||
expertise_id=expertise_id,
|
||||
confidence_level=50, # Közepes bizonyosság (crowdsourced)
|
||||
confidence_level=50,
|
||||
)
|
||||
db.add(expertise)
|
||||
logger.info(
|
||||
@@ -620,29 +607,8 @@ async def quick_add_provider(
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
# 5. Branch létrehozása atomizált címmezőkkel
|
||||
branch = Branch(
|
||||
organization_id=org.id,
|
||||
name=data.name[:100],
|
||||
is_main=True,
|
||||
city=data.city,
|
||||
postal_code=data.address_zip,
|
||||
street_name=data.address_street_name,
|
||||
street_type=data.address_street_type,
|
||||
house_number=data.address_house_number,
|
||||
status="active",
|
||||
)
|
||||
db.add(branch)
|
||||
|
||||
# =====================================================================
|
||||
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (KIHAGYVA - P0 ARCHITECTURE FIX)
|
||||
# =====================================================================
|
||||
# DÖNTÉS: A létrehozó felhasználó NEM lehet tagja a beszállító szervezetnek.
|
||||
# A szerviz jöjjön létre owner_id=None beállítással, tagok nélkül.
|
||||
# =====================================================================
|
||||
|
||||
# =====================================================================
|
||||
# 7. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
|
||||
# 5. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
|
||||
# =====================================================================
|
||||
earned_points = await _award_provider_points(
|
||||
db=db,
|
||||
@@ -652,15 +618,15 @@ async def quick_add_provider(
|
||||
# =====================================================================
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(org)
|
||||
await db.refresh(provider)
|
||||
|
||||
logger.info(
|
||||
f"Quick-add provider created: org_id={org.id}, name={data.name}, "
|
||||
f"Quick-add provider created: service_provider_id={provider.id}, name={data.name}, "
|
||||
f"user_id={user_id}, earned_points={earned_points}"
|
||||
)
|
||||
|
||||
return ProviderQuickAddResponse(
|
||||
id=org.id,
|
||||
id=provider.id,
|
||||
name=data.name,
|
||||
status="pending_verification",
|
||||
message="Szolgáltató sikeresen rögzítve. Ellenőrzés alatt.",
|
||||
|
||||
321
backend/app/services/rbac_service.py
Normal file
321
backend/app/services/rbac_service.py
Normal file
@@ -0,0 +1,321 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/rbac_service.py
|
||||
"""
|
||||
🎯 Scope-Based Admin RBAC Service (P0)
|
||||
|
||||
Implements scope-based access control for admin/staff roles.
|
||||
Instead of direct organization membership, admins are assigned
|
||||
a scope (e.g., REGION → "Pest_County") that determines which
|
||||
organizations they can manage.
|
||||
|
||||
Architecture:
|
||||
- SUPERADMIN: Unrestricted access (bypasses all scope checks)
|
||||
- ADMIN/MODERATOR: Full access within their assigned scope
|
||||
- SALES_REP/SERVICE_MGR: Scoped access with action-level filtering
|
||||
|
||||
Scope Types:
|
||||
- REGION: Geographic region (e.g., "Pest_County", "Budapest")
|
||||
- SEGMENT: Business segment (e.g., "Fleet", "Dealer", "Service")
|
||||
- GLOBAL: Unrestricted (equivalent to SUPERADMIN for scope purposes)
|
||||
|
||||
Usage:
|
||||
rbac = RBACService()
|
||||
await rbac.check_admin_access(db, user, "EDIT_DATA", target_org_id=42)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Set, List, Union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.identity import User, UserRole
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScopeType(str, Enum):
|
||||
"""The type of scope assigned to an admin user."""
|
||||
GLOBAL = "GLOBAL"
|
||||
REGION = "REGION"
|
||||
SEGMENT = "SEGMENT"
|
||||
|
||||
|
||||
class AdminAction(str, Enum):
|
||||
"""Admin actions that can be checked against scope."""
|
||||
# ── Data Management ──
|
||||
EDIT_DATA = "EDIT_DATA"
|
||||
VIEW_DATA = "VIEW_DATA"
|
||||
DELETE_DATA = "DELETE_DATA"
|
||||
EXPORT_DATA = "EXPORT_DATA"
|
||||
|
||||
# ── Subscription Management ──
|
||||
VIEW_SUBSCRIPTION = "VIEW_SUBSCRIPTION"
|
||||
EDIT_SUBSCRIPTION = "EDIT_SUBSCRIPTION"
|
||||
CANCEL_SUBSCRIPTION = "CANCEL_SUBSCRIPTION"
|
||||
|
||||
# ── User Management ──
|
||||
MANAGE_USERS = "MANAGE_USERS"
|
||||
SUSPEND_USERS = "SUSPEND_USERS"
|
||||
BAN_USERS = "BAN_USERS"
|
||||
|
||||
# ── Financial ──
|
||||
VIEW_FINANCIALS = "VIEW_FINANCIALS"
|
||||
MANAGE_BILLING = "MANAGE_BILLING"
|
||||
ISSUE_REFUNDS = "ISSUE_REFUNDS"
|
||||
|
||||
# ── Moderation ──
|
||||
APPROVE_CONTENT = "APPROVE_CONTENT"
|
||||
MODERATE_REVIEWS = "MODERATE_REVIEWS"
|
||||
VERIFY_PROVIDERS = "VERIFY_PROVIDERS"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ACTION-TO-ROLE MAPPING
|
||||
# Which roles are permitted to perform which actions
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# Actions that SUPERADMIN can always do (implicitly all)
|
||||
# Actions that ADMIN can do within their scope
|
||||
ADMIN_SCOPE_ACTIONS: Set[str] = {
|
||||
AdminAction.EDIT_DATA,
|
||||
AdminAction.VIEW_DATA,
|
||||
AdminAction.DELETE_DATA,
|
||||
AdminAction.EXPORT_DATA,
|
||||
AdminAction.VIEW_SUBSCRIPTION,
|
||||
AdminAction.EDIT_SUBSCRIPTION,
|
||||
AdminAction.MANAGE_USERS,
|
||||
AdminAction.SUSPEND_USERS,
|
||||
AdminAction.BAN_USERS,
|
||||
AdminAction.VIEW_FINANCIALS,
|
||||
AdminAction.MANAGE_BILLING,
|
||||
AdminAction.APPROVE_CONTENT,
|
||||
AdminAction.MODERATE_REVIEWS,
|
||||
AdminAction.VERIFY_PROVIDERS,
|
||||
}
|
||||
|
||||
# MODERATOR can do within their scope
|
||||
MODERATOR_SCOPE_ACTIONS: Set[str] = {
|
||||
AdminAction.VIEW_DATA,
|
||||
AdminAction.VIEW_SUBSCRIPTION,
|
||||
AdminAction.APPROVE_CONTENT,
|
||||
AdminAction.MODERATE_REVIEWS,
|
||||
AdminAction.VERIFY_PROVIDERS,
|
||||
AdminAction.SUSPEND_USERS,
|
||||
}
|
||||
|
||||
# SALES_REP can do within their scope
|
||||
SALES_REP_SCOPE_ACTIONS: Set[str] = {
|
||||
AdminAction.VIEW_DATA,
|
||||
AdminAction.VIEW_SUBSCRIPTION,
|
||||
AdminAction.EDIT_SUBSCRIPTION,
|
||||
AdminAction.VIEW_FINANCIALS,
|
||||
AdminAction.EXPORT_DATA,
|
||||
}
|
||||
|
||||
# SERVICE_MGR can do within their scope
|
||||
SERVICE_MGR_SCOPE_ACTIONS: Set[str] = {
|
||||
AdminAction.VIEW_DATA,
|
||||
AdminAction.EDIT_DATA,
|
||||
AdminAction.APPROVE_CONTENT,
|
||||
AdminAction.VERIFY_PROVIDERS,
|
||||
AdminAction.MODERATE_REVIEWS,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# RBAC SERVICE
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class RBACService:
|
||||
"""
|
||||
Scope-Based Admin RBAC Service.
|
||||
|
||||
Provides methods to check whether a staff user has access
|
||||
to perform a specific action on a target organization,
|
||||
based on the user's role and assigned scope.
|
||||
"""
|
||||
|
||||
# Mapping of role → set of permitted actions
|
||||
ROLE_ACTIONS: Dict[UserRole, Set[str]] = {
|
||||
UserRole.SUPERADMIN: set(AdminAction), # All actions
|
||||
UserRole.ADMIN: ADMIN_SCOPE_ACTIONS,
|
||||
UserRole.MODERATOR: MODERATOR_SCOPE_ACTIONS,
|
||||
UserRole.SALES_REP: SALES_REP_SCOPE_ACTIONS,
|
||||
UserRole.SERVICE_MGR: SERVICE_MGR_SCOPE_ACTIONS,
|
||||
}
|
||||
|
||||
async def check_admin_access(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
action: Union[str, AdminAction],
|
||||
target_org_id: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has scope-based access to perform an action.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user: The staff user to check
|
||||
action: The action being attempted (AdminAction enum or string)
|
||||
target_org_id: Optional target organization ID for scope matching
|
||||
|
||||
Returns:
|
||||
True if access is granted
|
||||
|
||||
Raises:
|
||||
PermissionError: If access is denied (with details)
|
||||
"""
|
||||
action_str = action.value if isinstance(action, AdminAction) else action
|
||||
|
||||
# ── 1. SUPERADMIN bypass ──
|
||||
if user.role == UserRole.SUPERADMIN:
|
||||
return True
|
||||
|
||||
# ── 2. Check if the role is permitted for this action ──
|
||||
permitted_actions = self.ROLE_ACTIONS.get(user.role, set())
|
||||
if action_str not in permitted_actions:
|
||||
raise PermissionError(
|
||||
f"Action '{action_str}' is not permitted for role '{user.role.value}'."
|
||||
)
|
||||
|
||||
# ── 3. If no target_org_id, just check action permission ──
|
||||
if target_org_id is None:
|
||||
return True
|
||||
|
||||
# ── 4. Scope-based organization access check ──
|
||||
return await self._check_org_scope_access(db, user, target_org_id)
|
||||
|
||||
async def _check_org_scope_access(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
target_org_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the user's scope covers the target organization.
|
||||
|
||||
The user's scope is defined by:
|
||||
- scope_type: "REGION", "SEGMENT", or "GLOBAL"
|
||||
- scope_value: The specific value (e.g., "Pest_County", "Fleet")
|
||||
|
||||
The organization's scope attributes:
|
||||
- region: Geographic region (e.g., "Pest_County")
|
||||
- segment: Business segment (e.g., "Fleet")
|
||||
"""
|
||||
# Get user scope info
|
||||
scope_type = getattr(user, 'scope_type', None)
|
||||
scope_value = getattr(user, 'scope_value', None)
|
||||
|
||||
# GLOBAL scope → access to all organizations
|
||||
if scope_type == ScopeType.GLOBAL.value:
|
||||
return True
|
||||
|
||||
# No scope assigned → deny
|
||||
if not scope_type or not scope_value:
|
||||
raise PermissionError(
|
||||
f"User {user.id} has no scope assigned. "
|
||||
f"Contact a SUPERADMIN to assign a scope."
|
||||
)
|
||||
|
||||
# Fetch the target organization
|
||||
stmt = select(Organization).where(Organization.id == target_org_id)
|
||||
result = await db.execute(stmt)
|
||||
org = result.scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
raise PermissionError(f"Organization {target_org_id} not found.")
|
||||
|
||||
# ── Scope matching logic ──
|
||||
if scope_type == ScopeType.REGION.value:
|
||||
# Check if org's region matches user's scope_value
|
||||
org_region = getattr(org, 'region', None)
|
||||
if not org_region:
|
||||
raise PermissionError(
|
||||
f"Organization {target_org_id} has no region assigned. "
|
||||
f"Cannot match against REGION scope '{scope_value}'."
|
||||
)
|
||||
if org_region != scope_value:
|
||||
raise PermissionError(
|
||||
f"REGION scope mismatch: user scope='{scope_value}', "
|
||||
f"org region='{org_region}'."
|
||||
)
|
||||
return True
|
||||
|
||||
elif scope_type == ScopeType.SEGMENT.value:
|
||||
# Check if org's segment matches user's scope_value
|
||||
org_segment = getattr(org, 'segment', None)
|
||||
if not org_segment:
|
||||
raise PermissionError(
|
||||
f"Organization {target_org_id} has no segment assigned. "
|
||||
f"Cannot match against SEGMENT scope '{scope_value}'."
|
||||
)
|
||||
if org_segment != scope_value:
|
||||
raise PermissionError(
|
||||
f"SEGMENT scope mismatch: user scope='{scope_value}', "
|
||||
f"org segment='{org_segment}'."
|
||||
)
|
||||
return True
|
||||
|
||||
else:
|
||||
raise PermissionError(f"Unknown scope type: '{scope_type}'.")
|
||||
|
||||
async def get_accessible_org_ids(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
) -> Optional[List[int]]:
|
||||
"""
|
||||
Get list of organization IDs accessible by this user based on their scope.
|
||||
|
||||
Returns None for SUPERADMIN/GLOBAL scope (unrestricted).
|
||||
Returns a list of matching org IDs for REGION/SEGMENT scope.
|
||||
"""
|
||||
# SUPERADMIN and GLOBAL scope → unrestricted
|
||||
if user.role == UserRole.SUPERADMIN:
|
||||
return None
|
||||
|
||||
scope_type = getattr(user, 'scope_type', None)
|
||||
scope_value = getattr(user, 'scope_value', None)
|
||||
|
||||
if not scope_type or not scope_value:
|
||||
return [] # No scope → no orgs
|
||||
|
||||
if scope_type == ScopeType.GLOBAL.value:
|
||||
return None
|
||||
|
||||
# Build query based on scope type
|
||||
if scope_type == ScopeType.REGION.value:
|
||||
stmt = select(Organization.id).where(
|
||||
Organization.region == scope_value,
|
||||
Organization.is_deleted == False
|
||||
)
|
||||
elif scope_type == ScopeType.SEGMENT.value:
|
||||
stmt = select(Organization.id).where(
|
||||
Organization.segment == scope_value,
|
||||
Organization.is_deleted == False
|
||||
)
|
||||
else:
|
||||
return []
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
def get_permitted_actions(self, role: UserRole) -> Set[str]:
|
||||
"""Get the set of actions permitted for a given role."""
|
||||
return self.ROLE_ACTIONS.get(role, set())
|
||||
|
||||
def is_action_permitted(self, role: UserRole, action: Union[str, AdminAction]) -> bool:
|
||||
"""Check if a specific action is permitted for a given role."""
|
||||
action_str = action.value if isinstance(action, AdminAction) else action
|
||||
return action_str in self.ROLE_ACTIONS.get(role, set())
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Singleton instance
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
rbac_service = RBACService()
|
||||
@@ -10,43 +10,64 @@ Hierarchia:
|
||||
- free: alap kategóriák (üzemanyag, szerviz, gumik, biztosítás, adók)
|
||||
- premium: free + útdíj/parkolás, ápolás, egyéb
|
||||
- enterprise: premium + minden (nincs korlátozás)
|
||||
|
||||
P0 Feature Flag System:
|
||||
- A get_user_tier() most már valós adatbázis lekérdezést végez a UserSubscription
|
||||
és OrganizationSubscription táblákból.
|
||||
- A get_user_feature_flags() metódus visszaadja az összes feature flag állapotát
|
||||
a felhasználó tier-je alapján.
|
||||
- A get_org_subscription_details() metódus visszaadja a szervezet előfizetési
|
||||
adatait a frontend számára.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.models.fleet_finance import CostCategory
|
||||
from app.models.core_logic import SubscriptionTier, UserSubscription, OrganizationSubscription
|
||||
from app.models.identity import User
|
||||
from app.models.marketplace.organization import Organization
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Subscription Feature Matrix ──────────────────────────────────────────────
|
||||
# Minden funkció/kategória minimális tier szintje.
|
||||
# A tier-ek: free < premium < enterprise
|
||||
TIER_HIERARCHY = {
|
||||
"free": 0,
|
||||
"premium": 1,
|
||||
"enterprise": 2,
|
||||
}
|
||||
# A tier-ek: 0=free, 1=premium, 2=enterprise
|
||||
# A TIER_HIERARCHY a SubscriptionTier.tier_level mezőből származik.
|
||||
# A SUBSCRIPTION_FEATURES itt a minimális tier_level értéket tárolja (int).
|
||||
|
||||
SUBSCRIPTION_FEATURES: dict[str, str] = {
|
||||
# Feature -> minimum required tier
|
||||
"cost_category:fuel": "free",
|
||||
"cost_category:service": "free",
|
||||
"cost_category:tires": "free",
|
||||
"cost_category:insurance": "free",
|
||||
"cost_category:taxes": "free",
|
||||
"cost_category:toll_parking": "premium",
|
||||
"cost_category:cleaning": "premium",
|
||||
"cost_category:other": "premium",
|
||||
"cost_category:all": "enterprise",
|
||||
SUBSCRIPTION_FEATURES: dict[str, int] = {
|
||||
# Feature -> minimum required tier_level (0=free, 1=premium, 2=enterprise)
|
||||
"cost_category:fuel": 0,
|
||||
"cost_category:service": 0,
|
||||
"cost_category:tires": 0,
|
||||
"cost_category:insurance": 0,
|
||||
"cost_category:taxes": 0,
|
||||
"cost_category:toll_parking": 1,
|
||||
"cost_category:cleaning": 1,
|
||||
"cost_category:other": 1,
|
||||
"cost_category:all": 2,
|
||||
# Feature flags
|
||||
"export_csv": "free",
|
||||
"analytics_tco": "premium",
|
||||
"analytics_detailed": "enterprise",
|
||||
"api_access": "free",
|
||||
"multi_vehicle": "free",
|
||||
"unlimited_vehicles": "premium",
|
||||
"export_csv": 0,
|
||||
"analytics_tco": 1,
|
||||
"analytics_detailed": 2,
|
||||
"api_access": 0,
|
||||
"multi_vehicle": 0,
|
||||
"unlimited_vehicles": 1,
|
||||
# P0 Frontend feature flags
|
||||
"subscription_management": 0,
|
||||
"vehicle_tracking": 0,
|
||||
"cost_tracking": 0,
|
||||
"service_booking": 0,
|
||||
"advanced_reports": 1,
|
||||
"team_management": 1,
|
||||
"priority_support": 1,
|
||||
"white_label": 2,
|
||||
"api_webhooks": 2,
|
||||
"custom_integrations": 2,
|
||||
}
|
||||
|
||||
|
||||
@@ -56,33 +77,346 @@ class SubscriptionService:
|
||||
|
||||
Metódusok:
|
||||
get_user_tier(db, user_id) -> str
|
||||
get_user_feature_flags(db, user_id) -> dict[str, bool]
|
||||
get_org_subscription_details(db, org_id) -> dict | None
|
||||
can_access_feature(tier, feature_key) -> bool
|
||||
get_visible_categories(db, tier) -> list[CostCategory]
|
||||
require_tier(tier, required_feature_or_min_tier) -> bool
|
||||
|
||||
P0 AUDIT FIX: A tier feloldás már a SubscriptionTier.tier_level mezőt használja
|
||||
ahelyett, hogy a tier nevét string-matchinggel próbálná meg kitalálni.
|
||||
A tier_level egy integer: 0=free, 1=premium, 2=enterprise.
|
||||
"""
|
||||
|
||||
# ── Tier level → display name mapping ──────────────────────────────
|
||||
# This is the canonical mapping from DB tier_level to the human-readable
|
||||
# entitlement tier name used in feature flag resolution.
|
||||
TIER_LEVEL_NAMES: dict[int, str] = {
|
||||
0: "free",
|
||||
1: "premium",
|
||||
2: "enterprise",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _tier_to_int(tier: str) -> int:
|
||||
"""Tier string konvertálása numerikus értékre a hierarchiában."""
|
||||
return TIER_HIERARCHY.get(tier.lower(), 0)
|
||||
def _tier_level_to_name(level: int) -> str:
|
||||
"""Convert a numeric tier_level to the canonical tier name."""
|
||||
return SubscriptionService.TIER_LEVEL_NAMES.get(level, "free")
|
||||
|
||||
@staticmethod
|
||||
async def _get_default_fallback_tier(db: AsyncSession) -> Optional[SubscriptionTier]:
|
||||
"""
|
||||
Lekérdezi az egyetlen csomagot, ahol is_default_fallback == True.
|
||||
Ha nincs ilyen, None-t ad vissza.
|
||||
"""
|
||||
stmt = select(SubscriptionTier).where(
|
||||
SubscriptionTier.is_default_fallback == True
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tier(db: AsyncSession, user_id: int) -> str:
|
||||
"""
|
||||
Visszaadja a felhasználó aktuális előfizetési szintjét.
|
||||
Visszaadja a felhasználó aktuális előfizetési szintjét valós adatbázis lekérdezéssel.
|
||||
|
||||
Jelenlegi implementáció: minden user 'free' tier-t kap.
|
||||
A jövőben ez a subscription/org táblákból lesz lekérdezve.
|
||||
P0 Feature Flag: Valós tier feloldás a UserSubscription és OrganizationSubscription
|
||||
táblákból, valamint a User.subscription_plan mezőből.
|
||||
|
||||
Feloldási sorrend:
|
||||
1. Aktív UserSubscription lekérdezése (finance.user_subscriptions)
|
||||
- Ha van aktív és nem járt le, a tier.tier_level alapján
|
||||
2. Ha nincs user subscription, de a user aktív szervezethez tartozik:
|
||||
- OrganizationSubscription lekérdezése
|
||||
3. Ha egyik sincs, visszaesés a User.subscription_plan mezőre
|
||||
4. Ha az is üres, ellenőrizzük a default fallback csomagot
|
||||
5. Alapértelmezett 'free'
|
||||
|
||||
P0 AUDIT FIX: A tier_level mezőt használjuk a SubscriptionTier modellből,
|
||||
nem pedig a tier name string-matchingjét.
|
||||
|
||||
P0 SINGLETON FALLBACK: Ha a subscription lejárt (expired), dinamikusan
|
||||
lekérdezzük a is_default_fallback==True csomagot és annak feature-jeit adjuk vissza.
|
||||
"""
|
||||
# TODO: Éles implementáció — subscription tábla lekérdezése
|
||||
# TODO: Szervezeti tier felülírás (pl. enterprise org)
|
||||
# ── 1. UserSubscription lekérdezése ──
|
||||
us_stmt = (
|
||||
select(UserSubscription)
|
||||
.options(selectinload(UserSubscription.tier))
|
||||
.where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True,
|
||||
)
|
||||
.order_by(UserSubscription.id.desc())
|
||||
)
|
||||
us_result = await db.execute(us_stmt)
|
||||
user_sub = us_result.scalars().first()
|
||||
|
||||
if user_sub and user_sub.tier:
|
||||
# Ellenőrizzük a lejáratot
|
||||
if user_sub.valid_until is None or user_sub.valid_until > datetime.utcnow():
|
||||
# P0 FIX: Use tier_level from DB instead of hardcoded string matching
|
||||
level = user_sub.tier.tier_level
|
||||
return SubscriptionService._tier_level_to_name(level)
|
||||
else:
|
||||
# ── P0 SINGLETON FALLBACK: subscription lejárt → default fallback tier ──
|
||||
logger.info(
|
||||
f"UserSubscription {user_sub.id} for user {user_id} has expired "
|
||||
f"(valid_until={user_sub.valid_until}). Falling back to default fallback tier."
|
||||
)
|
||||
fallback = await SubscriptionService._get_default_fallback_tier(db)
|
||||
if fallback:
|
||||
level = fallback.tier_level
|
||||
return SubscriptionService._tier_level_to_name(level)
|
||||
|
||||
# ── 2. Aktív szervezet subscription ──
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
# active_organization_id is stored in the User.scope_id column (String)
|
||||
# and set on current_user by the auth dependency from the JWT token.
|
||||
# When querying from DB directly, we use scope_id.
|
||||
active_org_id = None
|
||||
if user and user.scope_id:
|
||||
try:
|
||||
active_org_id = int(user.scope_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if active_org_id:
|
||||
org_stmt = (
|
||||
select(OrganizationSubscription)
|
||||
.options(selectinload(OrganizationSubscription.tier))
|
||||
.where(
|
||||
OrganizationSubscription.org_id == active_org_id,
|
||||
OrganizationSubscription.is_active == True,
|
||||
)
|
||||
.order_by(OrganizationSubscription.id.desc())
|
||||
)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_sub = org_result.scalars().first()
|
||||
|
||||
if org_sub and org_sub.tier:
|
||||
if org_sub.valid_until is None or org_sub.valid_until > datetime.utcnow():
|
||||
# P0 FIX: Use tier_level from DB instead of hardcoded string matching
|
||||
level = org_sub.tier.tier_level
|
||||
return SubscriptionService._tier_level_to_name(level)
|
||||
else:
|
||||
# ── P0 SINGLETON FALLBACK: org subscription lejárt ──
|
||||
logger.info(
|
||||
f"OrganizationSubscription {org_sub.id} for org {active_org_id} has expired "
|
||||
f"(valid_until={org_sub.valid_until}). Falling back to default fallback tier."
|
||||
)
|
||||
fallback = await SubscriptionService._get_default_fallback_tier(db)
|
||||
if fallback:
|
||||
level = fallback.tier_level
|
||||
return SubscriptionService._tier_level_to_name(level)
|
||||
|
||||
# ── 3. Fallback: User.subscription_plan ──
|
||||
if user and user.subscription_plan:
|
||||
plan = user.subscription_plan.lower()
|
||||
# For the fallback plan field, we still need a simple heuristic
|
||||
# since there's no SubscriptionTier object. We use a minimal mapping.
|
||||
if "enterprise" in plan or "vip" in plan:
|
||||
return "enterprise"
|
||||
if "premium" in plan or "pro" in plan:
|
||||
return "premium"
|
||||
return "free"
|
||||
|
||||
# ── 4. Default fallback tier ──
|
||||
fallback = await SubscriptionService._get_default_fallback_tier(db)
|
||||
if fallback:
|
||||
level = fallback.tier_level
|
||||
return SubscriptionService._tier_level_to_name(level)
|
||||
|
||||
# ── 5. Alapértelmezett ──
|
||||
return "free"
|
||||
|
||||
@staticmethod
|
||||
async def get_user_feature_flags(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Visszaadja az összes feature flag állapotát a felhasználó tier-je alapján.
|
||||
|
||||
P0 Feature: Ez a metódus a frontend useFeatureFlag() composable számára
|
||||
biztosítja a backend által validált feature flag-eket.
|
||||
|
||||
Returns:
|
||||
Dict a következő struktúrával:
|
||||
{
|
||||
"tier": "premium",
|
||||
"features": {
|
||||
"export_csv": True,
|
||||
"analytics_tco": True,
|
||||
...
|
||||
},
|
||||
"expires_at": "2026-07-24T12:00:00Z" | None
|
||||
}
|
||||
"""
|
||||
user_tier = await SubscriptionService.get_user_tier(db, user_id)
|
||||
|
||||
# Lekérdezzük a lejárati időt is
|
||||
expires_at = None
|
||||
us_stmt = (
|
||||
select(UserSubscription)
|
||||
.where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True,
|
||||
)
|
||||
.order_by(UserSubscription.id.desc())
|
||||
)
|
||||
us_result = await db.execute(us_stmt)
|
||||
user_sub = us_result.scalars().first()
|
||||
if user_sub and user_sub.valid_until:
|
||||
expires_at = user_sub.valid_until.isoformat()
|
||||
|
||||
# Összes feature flag feloldása
|
||||
features = {}
|
||||
for feature_key, required_tier in SUBSCRIPTION_FEATURES.items():
|
||||
features[feature_key] = SubscriptionService.can_access_feature(user_tier, feature_key)
|
||||
|
||||
return {
|
||||
"tier": user_tier,
|
||||
"features": features,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_org_subscription_details(
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Visszaadja a szervezet előfizetési adatait a frontend számára.
|
||||
|
||||
P0 Feature: Ez a metódus a GET /subscriptions/my végpont számára
|
||||
biztosítja a szervezet előfizetési adatait.
|
||||
|
||||
Returns:
|
||||
Dict a következő struktúrával:
|
||||
{
|
||||
"tier_id": 16,
|
||||
"tier_name": "corp_premium_v1",
|
||||
"display_name": "Céges Prémium",
|
||||
"valid_from": "2026-06-01T00:00:00Z",
|
||||
"valid_until": "2026-07-01T00:00:00Z",
|
||||
"is_active": True,
|
||||
"allowances": { ... },
|
||||
"pricing": { ... },
|
||||
"feature_capabilities": { ... },
|
||||
}
|
||||
vagy None, ha nincs aktív előfizetés.
|
||||
"""
|
||||
stmt = (
|
||||
select(OrganizationSubscription)
|
||||
.options(selectinload(OrganizationSubscription.tier))
|
||||
.where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == True,
|
||||
)
|
||||
.order_by(OrganizationSubscription.id.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
org_sub = result.scalars().first()
|
||||
|
||||
if not org_sub or not org_sub.tier:
|
||||
return None
|
||||
|
||||
tier = org_sub.tier
|
||||
rules = tier.rules or {}
|
||||
|
||||
return {
|
||||
"tier_id": tier.id,
|
||||
"tier_name": tier.name,
|
||||
"display_name": rules.get("display_name", tier.name),
|
||||
"valid_from": org_sub.valid_from.isoformat() if org_sub.valid_from else None,
|
||||
"valid_until": org_sub.valid_until.isoformat() if org_sub.valid_until else None,
|
||||
"is_active": org_sub.is_active,
|
||||
"allowances": rules.get("allowances", {}),
|
||||
"pricing": rules.get("pricing", {}),
|
||||
"duration": rules.get("duration", {}),
|
||||
"ad_policy": rules.get("ad_policy", {}),
|
||||
"entitlements": rules.get("entitlements", []),
|
||||
"feature_capabilities": tier.feature_capabilities or {},
|
||||
"extra_allowances": org_sub.extra_allowances or {},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_user_subscription_details(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Visszaadja a felhasználó személyes előfizetési adatait.
|
||||
|
||||
P0 Feature: Ez a metódus a GET /subscriptions/my végpont számára
|
||||
biztosítja a felhasználói előfizetés adatait (ha nincs org subscription).
|
||||
|
||||
Returns:
|
||||
Dict a következő struktúrával:
|
||||
{
|
||||
"tier_id": 5,
|
||||
"tier_name": "private_pro_v1",
|
||||
"display_name": "Privát Pro",
|
||||
"valid_from": "...",
|
||||
"valid_until": "...",
|
||||
"is_active": True,
|
||||
"allowances": { ... },
|
||||
"pricing": { ... },
|
||||
}
|
||||
vagy None, ha nincs aktív előfizetés.
|
||||
"""
|
||||
stmt = (
|
||||
select(UserSubscription)
|
||||
.options(selectinload(UserSubscription.tier))
|
||||
.where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True,
|
||||
)
|
||||
.order_by(UserSubscription.id.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user_sub = result.scalars().first()
|
||||
|
||||
if not user_sub or not user_sub.tier:
|
||||
return None
|
||||
|
||||
tier = user_sub.tier
|
||||
rules = tier.rules or {}
|
||||
|
||||
return {
|
||||
"tier_id": tier.id,
|
||||
"tier_name": tier.name,
|
||||
"display_name": rules.get("display_name", tier.name),
|
||||
"valid_from": user_sub.valid_from.isoformat() if user_sub.valid_from else None,
|
||||
"valid_until": user_sub.valid_until.isoformat() if user_sub.valid_until else None,
|
||||
"is_active": user_sub.is_active,
|
||||
"allowances": rules.get("allowances", {}),
|
||||
"pricing": rules.get("pricing", {}),
|
||||
"duration": rules.get("duration", {}),
|
||||
"ad_policy": rules.get("ad_policy", {}),
|
||||
"entitlements": rules.get("entitlements", []),
|
||||
"feature_capabilities": tier.feature_capabilities or {},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_user_level(user_tier: str) -> int:
|
||||
"""
|
||||
Convert a user tier name to its numeric level.
|
||||
Uses the canonical TIER_LEVEL_NAMES mapping.
|
||||
"""
|
||||
reverse_map = {v: k for k, v in SubscriptionService.TIER_LEVEL_NAMES.items()}
|
||||
return reverse_map.get(user_tier.lower(), 0)
|
||||
|
||||
@staticmethod
|
||||
def can_access_feature(user_tier: str, feature_key: str) -> bool:
|
||||
"""
|
||||
Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz.
|
||||
|
||||
P0 AUDIT FIX: A SUBSCRIPTION_FEATURES most már integer tier_level értékeket
|
||||
tartalmaz (0=free, 1=premium, 2=enterprise), így nincs szükség string konverzióra.
|
||||
|
||||
Args:
|
||||
user_tier: A felhasználó tier szintje (free/premium/enterprise)
|
||||
feature_key: A funkció kulcsa (pl. 'cost_category:fuel', 'analytics_tco')
|
||||
@@ -90,14 +424,13 @@ class SubscriptionService:
|
||||
Returns:
|
||||
bool: True ha hozzáfér, False ha nem
|
||||
"""
|
||||
required_tier = SUBSCRIPTION_FEATURES.get(feature_key)
|
||||
if required_tier is None:
|
||||
required_level = SUBSCRIPTION_FEATURES.get(feature_key)
|
||||
if required_level is None:
|
||||
# Ismeretlen feature — alapértelmezetten tiltva
|
||||
logger.warning(f"Unknown feature key: {feature_key}")
|
||||
return False
|
||||
|
||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
||||
required_level = SubscriptionService._tier_to_int(required_tier)
|
||||
user_level = SubscriptionService._get_user_level(user_tier)
|
||||
return user_level >= required_level
|
||||
|
||||
@staticmethod
|
||||
@@ -121,7 +454,7 @@ class SubscriptionService:
|
||||
Returns:
|
||||
List[CostCategory]: A látható kategóriák listája
|
||||
"""
|
||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
||||
user_level = SubscriptionService._get_user_level(user_tier)
|
||||
|
||||
# Lekérjük az összes kategóriát
|
||||
stmt = select(CostCategory).order_by(CostCategory.id)
|
||||
@@ -132,7 +465,7 @@ class SubscriptionService:
|
||||
visible = []
|
||||
for cat in all_categories:
|
||||
cat_tier = getattr(cat, "min_tier", "free") or "free"
|
||||
cat_level = SubscriptionService._tier_to_int(cat_tier)
|
||||
cat_level = SubscriptionService._get_user_level(cat_tier)
|
||||
if user_level >= cat_level:
|
||||
visible.append(cat)
|
||||
|
||||
@@ -157,6 +490,75 @@ class SubscriptionService:
|
||||
)
|
||||
|
||||
# Ha nem feature kulcs, akkor tier névként kezeljük
|
||||
user_level = SubscriptionService._tier_to_int(user_tier)
|
||||
required_level = SubscriptionService._tier_to_int(required_feature_or_min_tier)
|
||||
user_level = SubscriptionService._get_user_level(user_tier)
|
||||
required_level = SubscriptionService._get_user_level(required_feature_or_min_tier)
|
||||
return user_level >= required_level
|
||||
|
||||
@staticmethod
|
||||
async def assign_default_trial(
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
user_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Új regisztrációnál automatikusan hozzárendeli a próbaidős csomagot.
|
||||
|
||||
P0 SINGLETON TRIAL: Lekérdezi a trial_days_on_signup > 0 csomagot.
|
||||
Ha van ilyen, létrehoz egy OrganizationSubscription rekordot a megadott
|
||||
org_id-hoz, ami a trial_days_on_signup nap múlva jár le.
|
||||
|
||||
Args:
|
||||
db: Adatbázis session
|
||||
org_id: A szervezet ID-ja, amelyhez a trial subscription tartozik
|
||||
user_id: A felhasználó ID-ja (naplózáshoz)
|
||||
|
||||
Returns:
|
||||
Dict a subscription adatokkal, vagy None ha nincs trial csomag beállítva.
|
||||
"""
|
||||
# Lekérdezzük a trial csomagot (ahol trial_days_on_signup > 0)
|
||||
stmt = select(SubscriptionTier).where(
|
||||
SubscriptionTier.trial_days_on_signup > 0
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
trial_tier = result.scalar_one_or_none()
|
||||
|
||||
if not trial_tier:
|
||||
logger.info(
|
||||
f"No trial tier configured (no tier with trial_days_on_signup > 0). "
|
||||
f"Skipping trial assignment for org {org_id}, user {user_id}."
|
||||
)
|
||||
return None
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
# Számoljuk a lejárati időt
|
||||
trial_days = trial_tier.trial_days_on_signup
|
||||
now = datetime.utcnow()
|
||||
valid_until = now + timedelta(days=trial_days)
|
||||
|
||||
# Létrehozzuk a subscription rekordot
|
||||
org_sub = OrganizationSubscription(
|
||||
org_id=org_id,
|
||||
tier_id=trial_tier.id,
|
||||
valid_from=now,
|
||||
valid_until=valid_until,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(org_sub)
|
||||
await db.commit()
|
||||
await db.refresh(org_sub)
|
||||
|
||||
logger.info(
|
||||
f"Assigned trial subscription for org {org_id}, user {user_id}: "
|
||||
f"tier='{trial_tier.name}' (id={trial_tier.id}), "
|
||||
f"trial_days={trial_days}, valid_until={valid_until}"
|
||||
)
|
||||
|
||||
return {
|
||||
"tier_id": trial_tier.id,
|
||||
"tier_name": trial_tier.name,
|
||||
"valid_from": now.isoformat(),
|
||||
"valid_until": valid_until.isoformat(),
|
||||
"is_active": True,
|
||||
"trial_days": trial_days,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user