new kw for it services & sectors alignment & viewer access partial fixed
This commit is contained in:
@@ -171,6 +171,7 @@ class TeamManagementViewSet(viewsets.ViewSet):
|
||||
'email': user.email,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'role': user.role,
|
||||
'is_active': user.is_active,
|
||||
'is_staff': user.is_staff,
|
||||
'date_joined': user.date_joined.isoformat(),
|
||||
@@ -182,9 +183,11 @@ class TeamManagementViewSet(viewsets.ViewSet):
|
||||
})
|
||||
|
||||
def create(self, request):
|
||||
"""Invite new team member"""
|
||||
"""Invite new team member with role and optional site access."""
|
||||
account = request.user.account
|
||||
email = request.data.get('email')
|
||||
role = request.data.get('role', 'viewer')
|
||||
site_ids = request.data.get('site_ids', []) # For viewer role: which sites to grant access
|
||||
|
||||
if not email:
|
||||
return Response(
|
||||
@@ -192,6 +195,13 @@ class TeamManagementViewSet(viewsets.ViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Validate role - only admin and viewer allowed for invites
|
||||
if role not in ['admin', 'viewer']:
|
||||
return Response(
|
||||
{'error': 'Role must be either "admin" or "viewer"'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if user already exists
|
||||
if User.objects.filter(email=email).exists():
|
||||
return Response(
|
||||
@@ -217,15 +227,39 @@ class TeamManagementViewSet(viewsets.ViewSet):
|
||||
username = f"{base_username}{counter}"
|
||||
counter += 1
|
||||
|
||||
# Create user and send invitation email
|
||||
# Create user with assigned role
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
email=email,
|
||||
first_name=request.data.get('first_name', ''),
|
||||
last_name=request.data.get('last_name', ''),
|
||||
account=account
|
||||
account=account,
|
||||
role=role,
|
||||
)
|
||||
|
||||
# Grant site access based on role
|
||||
from igny8_core.auth.models import SiteUserAccess, Site
|
||||
if role == 'viewer' and site_ids:
|
||||
# Viewer: grant access only to specified sites
|
||||
sites = Site.objects.filter(id__in=site_ids, account=account)
|
||||
for site in sites:
|
||||
SiteUserAccess.objects.get_or_create(
|
||||
user=user,
|
||||
site=site,
|
||||
defaults={'granted_by': request.user}
|
||||
)
|
||||
elif role == 'admin':
|
||||
# Admin: automatically grant access to ALL current sites
|
||||
# (admin also sees all sites via get_accessible_sites, but
|
||||
# creating records ensures consistency)
|
||||
sites = Site.objects.filter(account=account)
|
||||
for site in sites:
|
||||
SiteUserAccess.objects.get_or_create(
|
||||
user=user,
|
||||
site=site,
|
||||
defaults={'granted_by': request.user}
|
||||
)
|
||||
|
||||
# Create password reset token for invite
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = timezone.now() + timedelta(hours=24)
|
||||
@@ -248,6 +282,7 @@ class TeamManagementViewSet(viewsets.ViewSet):
|
||||
'email': user.email,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'role': user.role,
|
||||
}
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@@ -89,8 +89,8 @@ class IsViewerOrAbove(permissions.BasePermission):
|
||||
# Check user role
|
||||
if hasattr(request.user, 'role'):
|
||||
role = request.user.role
|
||||
# viewer, editor, admin, owner all have access
|
||||
allowed = role in ['viewer', 'editor', 'admin', 'owner']
|
||||
# viewer, editor, admin, owner, developer, system_bot all have access
|
||||
allowed = role in ['viewer', 'editor', 'admin', 'owner', 'developer', 'system_bot']
|
||||
if allowed:
|
||||
logger.info(f"[IsViewerOrAbove] ALLOWED: User {request.user.email} has role {role}")
|
||||
else:
|
||||
@@ -114,8 +114,8 @@ class IsEditorOrAbove(permissions.BasePermission):
|
||||
# Check user role
|
||||
if hasattr(request.user, 'role'):
|
||||
role = request.user.role
|
||||
# editor, admin, owner have access
|
||||
return role in ['editor', 'admin', 'owner']
|
||||
# editor, admin, owner, developer have access
|
||||
return role in ['editor', 'admin', 'owner', 'developer']
|
||||
|
||||
# If no role system, allow authenticated users
|
||||
return True
|
||||
|
||||
@@ -13,7 +13,7 @@ from rest_framework.views import APIView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove, IsViewerOrAbove
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.auth.models import Site
|
||||
@@ -74,7 +74,13 @@ class UnifiedSiteSettingsViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
|
||||
throttle_scope = 'settings'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
|
||||
def get_permissions(self):
|
||||
"""Viewers can read settings; writes require editor+."""
|
||||
if self.action == 'retrieve':
|
||||
return [IsAuthenticatedAndActive(), IsViewerOrAbove()]
|
||||
return [IsAuthenticatedAndActive(), IsEditorOrAbove()]
|
||||
|
||||
def retrieve(self, request, site_id=None):
|
||||
"""Get all settings for a site in one response"""
|
||||
site = get_object_or_404(Site, id=site_id, account=request.user.account)
|
||||
|
||||
@@ -25,7 +25,7 @@ from .serializers import (
|
||||
IndustrySerializer, IndustrySectorSerializer, SeedKeywordSerializer,
|
||||
RefreshTokenSerializer, RequestPasswordResetSerializer, ResetPasswordSerializer
|
||||
)
|
||||
from .permissions import IsOwnerOrAdmin, IsEditorOrAbove
|
||||
from .permissions import IsOwnerOrAdmin, IsEditorOrAbove, IsViewerOrAbove
|
||||
from .utils import generate_access_token, generate_refresh_token, get_token_expiry, decode_token
|
||||
from .models import PasswordResetToken
|
||||
import jwt
|
||||
@@ -494,6 +494,17 @@ class PlanViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
|
||||
|
||||
class _IsOwnerOnly(permissions.BasePermission):
|
||||
"""Only owner or developer can perform this action (e.g., create sites)."""
|
||||
def has_permission(self, request, view):
|
||||
user = getattr(request, 'user', None)
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if getattr(user, 'is_superuser', False):
|
||||
return True
|
||||
return getattr(user, 'role', '') in ['owner', 'developer']
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['Authentication']),
|
||||
create=extend_schema(tags=['Authentication']),
|
||||
@@ -509,14 +520,16 @@ class SiteViewSet(AccountModelViewSet):
|
||||
authentication_classes = [JWTAuthentication]
|
||||
|
||||
def get_permissions(self):
|
||||
"""Allow normal users (viewer) to create sites, but require editor+ for other operations."""
|
||||
"""Viewers can list/retrieve sites; creation restricted to owner; writes require editor+."""
|
||||
# Allow public read access for list requests with slug filter (used by Sites Renderer)
|
||||
if self.action == 'list' and self.request.query_params.get('slug'):
|
||||
from rest_framework.permissions import AllowAny
|
||||
return [AllowAny()]
|
||||
if self.action == 'create':
|
||||
# For create, only require authentication - not active account status
|
||||
return [permissions.IsAuthenticated()]
|
||||
# Only owners and developers can create new sites (admin cannot)
|
||||
return [permissions.IsAuthenticated(), _IsOwnerOnly()]
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return [IsAuthenticatedAndActive(), HasTenantAccess(), IsViewerOrAbove()]
|
||||
return [IsAuthenticatedAndActive(), HasTenantAccess(), IsEditorOrAbove()]
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -772,7 +785,13 @@ class SectorViewSet(AccountModelViewSet):
|
||||
serializer_class = SectorSerializer
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsEditorOrAbove]
|
||||
authentication_classes = [JWTAuthentication]
|
||||
|
||||
|
||||
def get_permissions(self):
|
||||
"""Viewers can list/retrieve sectors; writes require editor+."""
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return [IsAuthenticatedAndActive(), HasTenantAccess(), IsViewerOrAbove()]
|
||||
return [IsAuthenticatedAndActive(), HasTenantAccess(), IsEditorOrAbove()]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return sectors from sites accessible to the current user."""
|
||||
user = self.request.user
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from igny8_core.api.base import SiteSectorModelViewSet
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove, IsViewerOrAbove
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.auth.models import Site
|
||||
@@ -39,7 +39,12 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
||||
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
|
||||
throttle_scope = 'integration'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return [IsAuthenticatedAndActive(), IsViewerOrAbove()]
|
||||
return [IsAuthenticatedAndActive(), IsEditorOrAbove()]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Override to filter integrations by site.
|
||||
@@ -998,6 +1003,11 @@ class PublishingSettingsViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
|
||||
throttle_scope = 'integration'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == 'retrieve':
|
||||
return [IsAuthenticatedAndActive(), IsViewerOrAbove()]
|
||||
return [IsAuthenticatedAndActive(), IsEditorOrAbove()]
|
||||
|
||||
def _get_site(self, site_id, request):
|
||||
"""Get site and verify user has access"""
|
||||
|
||||
@@ -14,7 +14,7 @@ from django.utils.decorators import method_decorator
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from igny8_core.api.base import SiteSectorModelViewSet
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove, IsViewerOrAbove
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.business.publishing.models import PublishingRecord, DeploymentRecord
|
||||
@@ -37,7 +37,12 @@ class PublishingRecordViewSet(SiteSectorModelViewSet):
|
||||
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
|
||||
throttle_scope = 'publisher'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return [IsAuthenticatedAndActive(), IsViewerOrAbove()]
|
||||
return [IsAuthenticatedAndActive(), IsEditorOrAbove()]
|
||||
|
||||
def get_serializer_class(self):
|
||||
# Dynamically create serializer
|
||||
from rest_framework import serializers
|
||||
@@ -67,7 +72,12 @@ class DeploymentRecordViewSet(SiteSectorModelViewSet):
|
||||
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
|
||||
throttle_scope = 'publisher'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return [IsAuthenticatedAndActive(), IsViewerOrAbove()]
|
||||
return [IsAuthenticatedAndActive(), IsEditorOrAbove()]
|
||||
|
||||
def get_serializer_class(self):
|
||||
# Dynamically create serializer
|
||||
from rest_framework import serializers
|
||||
@@ -89,6 +99,11 @@ class PublisherViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
|
||||
throttle_scope = 'publisher'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == 'get_status':
|
||||
return [IsAuthenticatedAndActive(), IsViewerOrAbove()]
|
||||
return [IsAuthenticatedAndActive(), IsEditorOrAbove()]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
188
backend/scripts/import_us_ca_tech.py
Normal file
188
backend/scripts/import_us_ca_tech.py
Normal file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Import US/CA Technology & IT Services Keywords
|
||||
|
||||
Targeted import script for the "US CA" folder in Technology_&_IT_Services.
|
||||
Maps folder names to existing DB sectors and imports all CSV files.
|
||||
|
||||
Usage:
|
||||
docker exec igny8_backend python3 /app/scripts/import_us_ca_tech.py --dry-run
|
||||
docker exec igny8_backend python3 /app/scripts/import_us_ca_tech.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import csv
|
||||
import argparse
|
||||
|
||||
sys.path.insert(0, '/app')
|
||||
os.chdir('/app')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
|
||||
import django
|
||||
django.setup()
|
||||
|
||||
from pathlib import Path
|
||||
from django.db import transaction
|
||||
from igny8_core.auth.models import Industry, IndustrySector, SeedKeyword
|
||||
|
||||
|
||||
# Hard-coded mapping: folder name in "US CA" -> sector slug in DB
|
||||
FOLDER_TO_SECTOR_SLUG = {
|
||||
'Automation & Workflow Systems': 'automation-workflow-systems',
|
||||
'Cloud Services': 'cloud-services',
|
||||
'DATA & AI Services': 'data-ai-services',
|
||||
'Digital Marketing & SEO': 'digital-marketing-seo',
|
||||
'SAAS': 'saas',
|
||||
'Web Development & Design': 'web-development-design',
|
||||
}
|
||||
|
||||
INDUSTRY_ID = 27 # Technology & IT Services
|
||||
|
||||
BASE_PATH = Path('/data/app/igny8/KW_DB/Technology_&_IT_Services/US CA')
|
||||
|
||||
|
||||
def parse_csv_row(row):
|
||||
"""Parse a CSV row into keyword data."""
|
||||
keyword = row.get('Keyword', '').strip()
|
||||
if not keyword:
|
||||
return None
|
||||
|
||||
country_raw = row.get('Country', 'US').strip().upper()
|
||||
if not country_raw:
|
||||
country_raw = 'US'
|
||||
|
||||
volume_raw = row.get('Volume', '0').strip()
|
||||
try:
|
||||
volume = int(volume_raw) if volume_raw else 0
|
||||
except (ValueError, TypeError):
|
||||
volume = 0
|
||||
|
||||
difficulty_raw = row.get('Difficulty', '0').strip()
|
||||
try:
|
||||
difficulty = int(difficulty_raw) if difficulty_raw else 0
|
||||
difficulty = max(0, min(100, difficulty))
|
||||
except (ValueError, TypeError):
|
||||
difficulty = 0
|
||||
|
||||
return {
|
||||
'keyword': keyword,
|
||||
'country': country_raw,
|
||||
'volume': volume,
|
||||
'difficulty': difficulty,
|
||||
}
|
||||
|
||||
|
||||
def import_csv(csv_path, industry, sector, dry_run=False):
|
||||
"""Import a single CSV file, returns stats dict."""
|
||||
stats = {'processed': 0, 'imported': 0, 'skipped_dup': 0, 'skipped_inv': 0, 'errors': 0}
|
||||
|
||||
try:
|
||||
with open(csv_path, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
stats['processed'] += 1
|
||||
data = parse_csv_row(row)
|
||||
if not data:
|
||||
stats['skipped_inv'] += 1
|
||||
continue
|
||||
|
||||
# Duplicate check: keyword (case-insensitive) in same industry+sector
|
||||
# DB unique constraint is (keyword, industry_id, sector_id) - no country
|
||||
exists = SeedKeyword.objects.filter(
|
||||
keyword__iexact=data['keyword'],
|
||||
industry=industry,
|
||||
sector=sector,
|
||||
).exists()
|
||||
|
||||
if exists:
|
||||
stats['skipped_dup'] += 1
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
SeedKeyword.objects.create(
|
||||
keyword=data['keyword'],
|
||||
industry=industry,
|
||||
sector=sector,
|
||||
volume=data['volume'],
|
||||
difficulty=data['difficulty'],
|
||||
country=data['country'],
|
||||
is_active=True,
|
||||
)
|
||||
stats['imported'] += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error: {e}")
|
||||
stats['errors'] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Import US/CA Tech keywords')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Preview without saving')
|
||||
args = parser.parse_args()
|
||||
|
||||
industry = Industry.objects.get(id=INDUSTRY_ID)
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f"IMPORT: Technology & IT Services — US CA Keywords")
|
||||
print(f"{'='*70}")
|
||||
print(f"Industry: {industry.name} (id={industry.id})")
|
||||
print(f"Mode: {'DRY RUN' if args.dry_run else 'LIVE IMPORT'}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
totals = {'files': 0, 'processed': 0, 'imported': 0, 'skipped_dup': 0, 'skipped_inv': 0, 'errors': 0}
|
||||
|
||||
for folder_name, sector_slug in sorted(FOLDER_TO_SECTOR_SLUG.items()):
|
||||
folder_path = BASE_PATH / folder_name
|
||||
if not folder_path.exists():
|
||||
print(f"⚠ Folder not found: {folder_name}")
|
||||
continue
|
||||
|
||||
sector = IndustrySector.objects.get(industry=industry, slug=sector_slug)
|
||||
print(f"\n📂 Sector: {sector.name} (id={sector.id}, slug={sector.slug})")
|
||||
|
||||
csv_files = sorted(folder_path.glob('*.csv'))
|
||||
if not csv_files:
|
||||
print(f" ⚠ No CSV files")
|
||||
continue
|
||||
|
||||
print(f" Found {len(csv_files)} CSV files")
|
||||
|
||||
with transaction.atomic():
|
||||
for csv_file in csv_files:
|
||||
totals['files'] += 1
|
||||
stats = import_csv(csv_file, industry, sector, dry_run=args.dry_run)
|
||||
|
||||
totals['processed'] += stats['processed']
|
||||
totals['imported'] += stats['imported']
|
||||
totals['skipped_dup'] += stats['skipped_dup']
|
||||
totals['skipped_inv'] += stats['skipped_inv']
|
||||
totals['errors'] += stats['errors']
|
||||
|
||||
print(f" 📄 {csv_file.name}")
|
||||
print(f" rows={stats['processed']} | ✓ imported={stats['imported']} | ⊘ dup={stats['skipped_dup']} | inv={stats['skipped_inv']}")
|
||||
|
||||
if args.dry_run:
|
||||
transaction.set_rollback(True)
|
||||
|
||||
print(f"\n\n{'='*70}")
|
||||
print(f"SUMMARY")
|
||||
print(f"{'='*70}")
|
||||
print(f"Total CSV files: {totals['files']}")
|
||||
print(f"Total rows processed: {totals['processed']}")
|
||||
print(f"✓ Imported: {totals['imported']}")
|
||||
print(f"⊘ Skipped (duplicate): {totals['skipped_dup']}")
|
||||
print(f"⊘ Skipped (invalid): {totals['skipped_inv']}")
|
||||
print(f"✗ Errors: {totals['errors']}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
if args.dry_run:
|
||||
print("ℹ DRY RUN — no data saved. Remove --dry-run to import.\n")
|
||||
else:
|
||||
print("✓ Import completed!\n")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user