account, schduels, timezone profile and many imporant updates
This commit is contained in:
@@ -11,12 +11,66 @@ from django.db.models import Q, Count, Sum
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
import secrets
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from igny8_core.auth.models import Account
|
||||
from igny8_core.auth.models import Account, PasswordResetToken
|
||||
|
||||
COUNTRY_TIMEZONE_MAP = {
|
||||
'US': 'America/New_York',
|
||||
'GB': 'Europe/London',
|
||||
'CA': 'America/Toronto',
|
||||
'AU': 'Australia/Sydney',
|
||||
'IN': 'Asia/Kolkata',
|
||||
'PK': 'Asia/Karachi',
|
||||
'DE': 'Europe/Berlin',
|
||||
'FR': 'Europe/Paris',
|
||||
'ES': 'Europe/Madrid',
|
||||
'IT': 'Europe/Rome',
|
||||
'NL': 'Europe/Amsterdam',
|
||||
'SE': 'Europe/Stockholm',
|
||||
'NO': 'Europe/Oslo',
|
||||
'DK': 'Europe/Copenhagen',
|
||||
'FI': 'Europe/Helsinki',
|
||||
'BE': 'Europe/Brussels',
|
||||
'AT': 'Europe/Vienna',
|
||||
'CH': 'Europe/Zurich',
|
||||
'IE': 'Europe/Dublin',
|
||||
'NZ': 'Pacific/Auckland',
|
||||
'SG': 'Asia/Singapore',
|
||||
'AE': 'Asia/Dubai',
|
||||
'SA': 'Asia/Riyadh',
|
||||
'ZA': 'Africa/Johannesburg',
|
||||
'BR': 'America/Sao_Paulo',
|
||||
'MX': 'America/Mexico_City',
|
||||
'AR': 'America/Argentina/Buenos_Aires',
|
||||
'CL': 'America/Santiago',
|
||||
'CO': 'America/Bogota',
|
||||
'JP': 'Asia/Tokyo',
|
||||
'KR': 'Asia/Seoul',
|
||||
'CN': 'Asia/Shanghai',
|
||||
'TH': 'Asia/Bangkok',
|
||||
'MY': 'Asia/Kuala_Lumpur',
|
||||
'ID': 'Asia/Jakarta',
|
||||
'PH': 'Asia/Manila',
|
||||
'VN': 'Asia/Ho_Chi_Minh',
|
||||
'BD': 'Asia/Dhaka',
|
||||
'LK': 'Asia/Colombo',
|
||||
'EG': 'Africa/Cairo',
|
||||
'NG': 'Africa/Lagos',
|
||||
'KE': 'Africa/Nairobi',
|
||||
'GH': 'Africa/Accra',
|
||||
}
|
||||
|
||||
def _timezone_for_country(country_code: str | None) -> str:
|
||||
if not country_code:
|
||||
return 'UTC'
|
||||
return COUNTRY_TIMEZONE_MAP.get(country_code.upper(), 'UTC')
|
||||
from igny8_core.business.billing.models import CreditTransaction
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -43,6 +97,9 @@ class AccountSettingsViewSet(viewsets.ViewSet):
|
||||
'billing_country': account.billing_country or '',
|
||||
'tax_id': account.tax_id or '',
|
||||
'billing_email': account.billing_email or '',
|
||||
'account_timezone': account.account_timezone or 'UTC',
|
||||
'timezone_mode': account.timezone_mode or 'country',
|
||||
'timezone_offset': account.timezone_offset or '',
|
||||
'credits': account.credits,
|
||||
'created_at': account.created_at.isoformat(),
|
||||
'updated_at': account.updated_at.isoformat(),
|
||||
@@ -56,12 +113,19 @@ class AccountSettingsViewSet(viewsets.ViewSet):
|
||||
allowed_fields = [
|
||||
'name', 'billing_address_line1', 'billing_address_line2',
|
||||
'billing_city', 'billing_state', 'billing_postal_code',
|
||||
'billing_country', 'tax_id', 'billing_email'
|
||||
'billing_country', 'tax_id', 'billing_email',
|
||||
'account_timezone', 'timezone_mode', 'timezone_offset'
|
||||
]
|
||||
|
||||
for field in allowed_fields:
|
||||
if field in request.data:
|
||||
setattr(account, field, request.data[field])
|
||||
|
||||
# Derive timezone from country unless manual mode is selected
|
||||
if getattr(account, 'timezone_mode', 'country') != 'manual':
|
||||
country_code = account.billing_country
|
||||
account.account_timezone = _timezone_for_country(country_code)
|
||||
account.timezone_offset = ''
|
||||
|
||||
account.save()
|
||||
|
||||
@@ -79,6 +143,9 @@ class AccountSettingsViewSet(viewsets.ViewSet):
|
||||
'billing_country': account.billing_country,
|
||||
'tax_id': account.tax_id,
|
||||
'billing_email': account.billing_email,
|
||||
'account_timezone': account.account_timezone,
|
||||
'timezone_mode': account.timezone_mode,
|
||||
'timezone_offset': account.timezone_offset,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -142,13 +209,37 @@ class TeamManagementViewSet(viewsets.ViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Create user (simplified - in production, send invitation email)
|
||||
# Generate username from email if not provided
|
||||
base_username = email.split('@')[0]
|
||||
username = base_username
|
||||
counter = 1
|
||||
while User.objects.filter(username=username).exists():
|
||||
username = f"{base_username}{counter}"
|
||||
counter += 1
|
||||
|
||||
# Create user and send invitation email
|
||||
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
|
||||
)
|
||||
|
||||
# Create password reset token for invite
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = timezone.now() + timedelta(hours=24)
|
||||
PasswordResetToken.objects.create(
|
||||
user=user,
|
||||
token=token,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import send_team_invite_email
|
||||
send_team_invite_email(user, request.user, account, token)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send team invite email: {e}")
|
||||
|
||||
return Response({
|
||||
'message': 'Team member invited successfully',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0031_drop_all_blueprint_tables'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='account_timezone',
|
||||
field=models.CharField(default='UTC', help_text='IANA timezone name', max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='timezone_mode',
|
||||
field=models.CharField(choices=[('country', 'Country'), ('manual', 'Manual')], default='country', help_text='Timezone selection mode', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='timezone_offset',
|
||||
field=models.CharField(blank=True, default='', help_text='Optional UTC offset label', max_length=10),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-19 00:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0032_add_account_timezone_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='historicalaccount',
|
||||
name='account_timezone',
|
||||
field=models.CharField(default='UTC', max_length=64, help_text='IANA timezone name'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalaccount',
|
||||
name='timezone_mode',
|
||||
field=models.CharField(choices=[('country', 'Country'), ('manual', 'Manual')], default='country', max_length=20, help_text='Timezone selection mode'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalaccount',
|
||||
name='timezone_offset',
|
||||
field=models.CharField(blank=True, default='', max_length=10, help_text='Optional UTC offset label'),
|
||||
),
|
||||
]
|
||||
18
backend/igny8_core/auth/migrations/0034_add_user_phone.py
Normal file
18
backend/igny8_core/auth/migrations/0034_add_user_phone.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-19 00:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0033_add_account_timezone_history_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='phone',
|
||||
field=models.CharField(blank=True, default='', max_length=30),
|
||||
),
|
||||
]
|
||||
@@ -106,6 +106,16 @@ class Account(SoftDeletableModel):
|
||||
billing_postal_code = models.CharField(max_length=20, blank=True)
|
||||
billing_country = models.CharField(max_length=2, blank=True, help_text="ISO 2-letter country code")
|
||||
tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number")
|
||||
|
||||
# Account timezone (single source of truth for all users/sites)
|
||||
account_timezone = models.CharField(max_length=64, default='UTC', help_text="IANA timezone name")
|
||||
timezone_mode = models.CharField(
|
||||
max_length=20,
|
||||
choices=[('country', 'Country'), ('manual', 'Manual')],
|
||||
default='country',
|
||||
help_text="Timezone selection mode"
|
||||
)
|
||||
timezone_offset = models.CharField(max_length=10, blank=True, default='', help_text="Optional UTC offset label")
|
||||
|
||||
# Monthly usage tracking (reset on billing cycle)
|
||||
usage_ahrefs_queries = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Ahrefs queries used this month")
|
||||
@@ -922,6 +932,7 @@ class User(AbstractUser):
|
||||
account = models.ForeignKey('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='users', null=True, blank=True, db_column='tenant_id')
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer')
|
||||
email = models.EmailField(_('email address'), unique=True)
|
||||
phone = models.CharField(max_length=30, blank=True, default='')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'owner', 'plan', 'plan_id',
|
||||
'credits', 'status', 'payment_method',
|
||||
'subscription', 'billing_country', 'created_at'
|
||||
'subscription', 'billing_country',
|
||||
'account_timezone', 'timezone_mode', 'timezone_offset',
|
||||
'created_at'
|
||||
]
|
||||
read_only_fields = ['owner', 'created_at']
|
||||
|
||||
@@ -270,7 +272,18 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'role', 'account', 'accessible_sites', 'created_at']
|
||||
fields = [
|
||||
'id',
|
||||
'username',
|
||||
'email',
|
||||
'phone',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'role',
|
||||
'account',
|
||||
'accessible_sites',
|
||||
'created_at',
|
||||
]
|
||||
read_only_fields = ['created_at']
|
||||
|
||||
def get_accessible_sites(self, obj):
|
||||
|
||||
@@ -255,6 +255,25 @@ class UsersViewSet(AccountModelViewSet):
|
||||
serializer = UserSerializer(user)
|
||||
return success_response(data={'user': serializer.data}, request=request)
|
||||
|
||||
@action(detail=False, methods=['get', 'patch'], permission_classes=[IsAuthenticatedAndActive])
|
||||
def me(self, request):
|
||||
"""Get or update the current user profile."""
|
||||
user = request.user
|
||||
|
||||
if request.method == 'PATCH':
|
||||
serializer = UserSerializer(user, data=request.data, partial=True)
|
||||
if not serializer.is_valid():
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
serializer.save()
|
||||
|
||||
serializer = UserSerializer(user)
|
||||
return success_response(data={'user': serializer.data}, request=request)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 3. ACCOUNTS - Register each unique organization/user space
|
||||
|
||||
@@ -1944,29 +1944,39 @@ class AutomationViewSet(viewsets.ViewSet):
|
||||
def server_time(self, request):
|
||||
"""
|
||||
GET /api/v1/automation/server_time/
|
||||
Get current server time (UTC) used for all automation scheduling.
|
||||
Get current time in the account timezone used for automation scheduling.
|
||||
|
||||
Returns:
|
||||
- server_time: Current UTC timestamp (ISO 8601 format)
|
||||
- server_time_formatted: Human-readable UTC time
|
||||
- timezone: Server timezone setting (always UTC)
|
||||
- server_time: Current timestamp (ISO 8601 format, account timezone)
|
||||
- server_time_formatted: Human-readable time (account timezone)
|
||||
- timezone: Account timezone setting
|
||||
- celery_timezone: Celery task timezone setting
|
||||
- use_tz: Whether Django is timezone-aware
|
||||
|
||||
Note: All automation schedules (scheduled_time) are in UTC.
|
||||
When user sets "02:00", the automation runs at 02:00 UTC.
|
||||
Note: Automation schedules are shown in the account timezone.
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
now = timezone.now()
|
||||
account = getattr(request.user, 'account', None)
|
||||
account_timezone = getattr(account, 'account_timezone', None) or 'UTC'
|
||||
|
||||
try:
|
||||
tzinfo = ZoneInfo(account_timezone)
|
||||
except Exception:
|
||||
tzinfo = timezone.utc
|
||||
account_timezone = 'UTC'
|
||||
|
||||
local_now = now.astimezone(tzinfo)
|
||||
|
||||
return Response({
|
||||
'server_time': now.isoformat(),
|
||||
'server_time_formatted': now.strftime('%H:%M'),
|
||||
'server_time_date': now.strftime('%Y-%m-%d'),
|
||||
'server_time_time': now.strftime('%H:%M:%S'),
|
||||
'timezone': settings.TIME_ZONE,
|
||||
'server_time': local_now.isoformat(),
|
||||
'server_time_formatted': local_now.strftime('%H:%M'),
|
||||
'server_time_date': local_now.strftime('%Y-%m-%d'),
|
||||
'server_time_time': local_now.strftime('%H:%M:%S'),
|
||||
'timezone': account_timezone,
|
||||
'celery_timezone': getattr(settings, 'CELERY_TIMEZONE', settings.TIME_ZONE),
|
||||
'use_tz': settings.USE_TZ,
|
||||
'note': 'All automation schedules are in UTC. When you set "02:00", the automation runs at 02:00 UTC.'
|
||||
'note': 'Automation schedules are shown in the account timezone.'
|
||||
})
|
||||
@@ -1271,6 +1271,31 @@ def send_password_reset_email(user, reset_token):
|
||||
)
|
||||
|
||||
|
||||
def send_team_invite_email(invited_user, inviter, account, reset_token):
|
||||
"""Send team invitation email with password setup link"""
|
||||
service = get_email_service()
|
||||
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
inviter_name = inviter.first_name or inviter.email
|
||||
invited_name = invited_user.first_name or invited_user.email
|
||||
|
||||
context = {
|
||||
'inviter_name': inviter_name,
|
||||
'invited_name': invited_name,
|
||||
'account_name': account.name,
|
||||
'reset_url': f'{frontend_url}/reset-password?token={reset_token}',
|
||||
'frontend_url': frontend_url,
|
||||
}
|
||||
|
||||
return service.send_transactional(
|
||||
to=invited_user.email,
|
||||
subject=f"You're invited to join {account.name} on IGNY8",
|
||||
template='emails/team_invite.html',
|
||||
context=context,
|
||||
tags=['auth', 'team-invite'],
|
||||
)
|
||||
|
||||
|
||||
def send_email_verification(user, verification_token):
|
||||
"""Send email verification link"""
|
||||
service = get_email_service()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-19 00:00
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def add_team_invite_template(apps, schema_editor):
|
||||
EmailTemplate = apps.get_model('system', 'EmailTemplate')
|
||||
|
||||
EmailTemplate.objects.get_or_create(
|
||||
template_name='team_invite',
|
||||
defaults={
|
||||
'template_path': 'emails/team_invite.html',
|
||||
'display_name': 'Team Invitation',
|
||||
'description': 'Sent when a team member is invited to join an account',
|
||||
'template_type': 'auth',
|
||||
'default_subject': "You're invited to join IGNY8",
|
||||
'required_context': ['inviter_name', 'invited_name', 'account_name', 'reset_url', 'frontend_url'],
|
||||
'sample_context': {
|
||||
'inviter_name': 'Alex Johnson',
|
||||
'invited_name': 'Jamie Lee',
|
||||
'account_name': 'Acme Co',
|
||||
'reset_url': 'https://app.igny8.com/reset-password?token=example',
|
||||
'frontend_url': 'https://app.igny8.com',
|
||||
},
|
||||
'is_active': True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def remove_team_invite_template(apps, schema_editor):
|
||||
EmailTemplate = apps.get_model('system', 'EmailTemplate')
|
||||
EmailTemplate.objects.filter(template_name='team_invite').delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0025_delete_accountintegrationoverride'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(add_team_invite_template, remove_team_invite_template),
|
||||
]
|
||||
@@ -1595,8 +1595,9 @@ class ContentViewSet(SiteSectorModelViewSet):
|
||||
}
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from igny8_core.business.integration.models import Site, SitePublishingSettings
|
||||
from igny8_core.auth.models import Site
|
||||
from igny8_core.business.integration.models import PublishingSettings
|
||||
from igny8_core.tasks.publishing_scheduler import _calculate_available_slots
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1621,7 +1622,7 @@ class ContentViewSet(SiteSectorModelViewSet):
|
||||
# Get site and publishing settings
|
||||
try:
|
||||
site = Site.objects.get(id=site_id)
|
||||
pub_settings = SitePublishingSettings.objects.filter(site=site).first()
|
||||
pub_settings, _ = PublishingSettings.get_or_create_for_site(site)
|
||||
except Site.DoesNotExist:
|
||||
return error_response(
|
||||
error=f'Site {site_id} not found',
|
||||
@@ -1629,41 +1630,25 @@ class ContentViewSet(SiteSectorModelViewSet):
|
||||
request=request
|
||||
)
|
||||
|
||||
# Default settings if none exist
|
||||
base_time_str = '09:00 AM'
|
||||
stagger_interval = 15 # minutes
|
||||
timezone_str = 'America/New_York'
|
||||
|
||||
if pub_settings:
|
||||
base_time_str = pub_settings.auto_publish_time or base_time_str
|
||||
stagger_interval = pub_settings.stagger_interval_minutes or stagger_interval
|
||||
timezone_str = pub_settings.timezone or timezone_str
|
||||
|
||||
# Get content items
|
||||
content_qs = self.get_queryset().filter(id__in=content_ids)
|
||||
|
||||
# Generate schedule preview
|
||||
# Generate schedule preview using the same slot logic as automation
|
||||
schedule_preview = []
|
||||
now = timezone.now()
|
||||
|
||||
# Parse base time (format: "09:00 AM" or "14:30")
|
||||
try:
|
||||
from datetime import datetime
|
||||
if 'AM' in base_time_str or 'PM' in base_time_str:
|
||||
time_obj = datetime.strptime(base_time_str, '%I:%M %p').time()
|
||||
else:
|
||||
time_obj = datetime.strptime(base_time_str, '%H:%M').time()
|
||||
except ValueError:
|
||||
time_obj = datetime.strptime('09:00', '%H:%M').time()
|
||||
|
||||
# Start from tomorrow at base time
|
||||
start_date = now.replace(hour=time_obj.hour, minute=time_obj.minute, second=0, microsecond=0)
|
||||
if start_date <= now:
|
||||
start_date += timedelta(days=1)
|
||||
|
||||
available_slots = _calculate_available_slots(pub_settings, site)
|
||||
|
||||
if not available_slots:
|
||||
return error_response(
|
||||
error='No available publishing slots based on site defaults',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Create schedule for each content item
|
||||
for index, content in enumerate(content_qs):
|
||||
scheduled_at = start_date + timedelta(minutes=stagger_interval * index)
|
||||
if index >= len(available_slots):
|
||||
break
|
||||
scheduled_at = available_slots[index]
|
||||
schedule_preview.append({
|
||||
'content_id': content.id,
|
||||
'title': content.title,
|
||||
@@ -1671,15 +1656,25 @@ class ContentViewSet(SiteSectorModelViewSet):
|
||||
})
|
||||
|
||||
logger.info(f"[bulk_schedule_preview] Generated preview for {len(schedule_preview)} items")
|
||||
|
||||
# Base time for display should match first scheduled slot
|
||||
display_base_time = '09:00'
|
||||
if schedule_preview:
|
||||
try:
|
||||
from datetime import datetime
|
||||
first_dt = datetime.fromisoformat(schedule_preview[0]['scheduled_at'])
|
||||
display_base_time = first_dt.strftime('%H:%M')
|
||||
except Exception:
|
||||
display_base_time = '09:00'
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'scheduled_count': len(schedule_preview),
|
||||
'schedule_preview': schedule_preview,
|
||||
'site_settings': {
|
||||
'base_time': base_time_str,
|
||||
'stagger_interval': stagger_interval,
|
||||
'timezone': timezone_str,
|
||||
'base_time': display_base_time,
|
||||
'stagger_interval': pub_settings.stagger_interval_minutes,
|
||||
'timezone': site.account.account_timezone if hasattr(site, 'account') else 'UTC',
|
||||
},
|
||||
},
|
||||
message=f'Preview generated for {len(schedule_preview)} items',
|
||||
@@ -1699,8 +1694,9 @@ class ContentViewSet(SiteSectorModelViewSet):
|
||||
}
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from igny8_core.business.integration.models import Site, SitePublishingSettings
|
||||
from igny8_core.auth.models import Site
|
||||
from igny8_core.business.integration.models import PublishingSettings
|
||||
from igny8_core.tasks.publishing_scheduler import _calculate_available_slots
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1726,7 +1722,7 @@ class ContentViewSet(SiteSectorModelViewSet):
|
||||
# Get site and publishing settings
|
||||
try:
|
||||
site = Site.objects.get(id=site_id)
|
||||
pub_settings = SitePublishingSettings.objects.filter(site=site).first()
|
||||
pub_settings, _ = PublishingSettings.get_or_create_for_site(site)
|
||||
except Site.DoesNotExist:
|
||||
return error_response(
|
||||
error=f'Site {site_id} not found',
|
||||
@@ -1734,39 +1730,28 @@ class ContentViewSet(SiteSectorModelViewSet):
|
||||
request=request
|
||||
)
|
||||
|
||||
# Default settings if none exist
|
||||
base_time_str = '09:00 AM'
|
||||
stagger_interval = 15 # minutes
|
||||
|
||||
if pub_settings and use_site_defaults:
|
||||
base_time_str = pub_settings.auto_publish_time or base_time_str
|
||||
stagger_interval = pub_settings.stagger_interval_minutes or stagger_interval
|
||||
|
||||
# Get content items
|
||||
content_qs = self.get_queryset().filter(id__in=content_ids)
|
||||
|
||||
# Generate schedule and apply
|
||||
# Generate schedule and apply using the same slot logic as automation
|
||||
available_slots = _calculate_available_slots(pub_settings, site) if use_site_defaults else []
|
||||
|
||||
if use_site_defaults and not available_slots:
|
||||
return error_response(
|
||||
error='No available publishing slots based on site defaults',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
now = timezone.now()
|
||||
|
||||
# Parse base time
|
||||
try:
|
||||
from datetime import datetime
|
||||
if 'AM' in base_time_str or 'PM' in base_time_str:
|
||||
time_obj = datetime.strptime(base_time_str, '%I:%M %p').time()
|
||||
else:
|
||||
time_obj = datetime.strptime(base_time_str, '%H:%M').time()
|
||||
except ValueError:
|
||||
time_obj = datetime.strptime('09:00', '%H:%M').time()
|
||||
|
||||
# Start from tomorrow at base time
|
||||
start_date = now.replace(hour=time_obj.hour, minute=time_obj.minute, second=0, microsecond=0)
|
||||
if start_date <= now:
|
||||
start_date += timedelta(days=1)
|
||||
|
||||
# Schedule each content item
|
||||
scheduled_count = 0
|
||||
for index, content in enumerate(content_qs):
|
||||
scheduled_at = start_date + timedelta(minutes=stagger_interval * index)
|
||||
if use_site_defaults:
|
||||
if index >= len(available_slots):
|
||||
break
|
||||
scheduled_at = available_slots[index]
|
||||
else:
|
||||
scheduled_at = now
|
||||
content.site_status = 'scheduled'
|
||||
content.scheduled_publish_at = scheduled_at
|
||||
content.site_status_updated_at = now
|
||||
|
||||
@@ -155,55 +155,27 @@ def _calculate_available_slots(settings: 'PublishingSettings', site: 'Site') ->
|
||||
allowed_days = [day_map.get(d.lower(), -1) for d in publish_days]
|
||||
allowed_days = [d for d in allowed_days if d >= 0]
|
||||
|
||||
# Calculate limits
|
||||
daily_limit = settings.daily_publish_limit
|
||||
weekly_limit = settings.weekly_publish_limit
|
||||
monthly_limit = settings.monthly_publish_limit
|
||||
# Calculate limits from configured publish days/slots
|
||||
daily_limit = settings.daily_capacity
|
||||
weekly_limit = settings.weekly_capacity
|
||||
monthly_limit = settings.monthly_capacity
|
||||
queue_limit = getattr(settings, 'queue_limit', 100) or 100
|
||||
|
||||
# Count existing scheduled/published content
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start = today_start - timedelta(days=now.weekday())
|
||||
month_start = today_start.replace(day=1)
|
||||
|
||||
daily_count = Content.objects.filter(
|
||||
site=site,
|
||||
site_status__in=['scheduled', 'publishing', 'published'],
|
||||
scheduled_publish_at__gte=today_start
|
||||
).count()
|
||||
|
||||
weekly_count = Content.objects.filter(
|
||||
site=site,
|
||||
site_status__in=['scheduled', 'publishing', 'published'],
|
||||
scheduled_publish_at__gte=week_start
|
||||
).count()
|
||||
|
||||
monthly_count = Content.objects.filter(
|
||||
site=site,
|
||||
site_status__in=['scheduled', 'publishing', 'published'],
|
||||
scheduled_publish_at__gte=month_start
|
||||
).count()
|
||||
|
||||
# Route to appropriate slot generator
|
||||
if settings.scheduling_mode == 'stagger':
|
||||
return _generate_stagger_slots(
|
||||
settings, site, now, allowed_days,
|
||||
daily_limit, weekly_limit, monthly_limit, queue_limit,
|
||||
daily_count, weekly_count, monthly_count
|
||||
)
|
||||
else:
|
||||
# Default to time_slots mode
|
||||
return _generate_time_slot_slots(
|
||||
settings, site, now, allowed_days,
|
||||
daily_limit, weekly_limit, monthly_limit, queue_limit,
|
||||
daily_count, weekly_count, monthly_count
|
||||
)
|
||||
# Always use time_slots mode for scheduling
|
||||
account_timezone = getattr(site.account, 'account_timezone', 'UTC') if hasattr(site, 'account') else 'UTC'
|
||||
|
||||
return _generate_time_slot_slots(
|
||||
settings, site, now, allowed_days,
|
||||
daily_limit, weekly_limit, monthly_limit, queue_limit,
|
||||
account_timezone
|
||||
)
|
||||
|
||||
|
||||
def _generate_time_slot_slots(
|
||||
settings, site, now, allowed_days,
|
||||
daily_limit, weekly_limit, monthly_limit, queue_limit,
|
||||
daily_count, weekly_count, monthly_count
|
||||
account_timezone: str
|
||||
) -> list:
|
||||
"""Generate slots based on specific time slots (original mode)."""
|
||||
from igny8_core.business.content.models import Content
|
||||
@@ -226,41 +198,87 @@ def _generate_time_slot_slots(
|
||||
current_date = now.date()
|
||||
slots_per_day = {}
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start = today_start - timedelta(days=now.weekday())
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
tzinfo = ZoneInfo(account_timezone or 'UTC')
|
||||
|
||||
for day_offset in range(90): # Look 90 days ahead
|
||||
check_date = current_date + timedelta(days=day_offset)
|
||||
|
||||
if check_date.weekday() not in allowed_days:
|
||||
continue
|
||||
|
||||
# Existing scheduled times for this day to avoid conflicts
|
||||
existing_times = set(
|
||||
Content.objects.filter(
|
||||
site=site,
|
||||
site_status__in=['scheduled', 'publishing', 'published'],
|
||||
scheduled_publish_at__date=check_date
|
||||
).values_list('scheduled_publish_at', flat=True)
|
||||
)
|
||||
|
||||
for hour, minute in time_slots:
|
||||
slot_time = timezone.make_aware(
|
||||
datetime.combine(check_date, datetime.min.time().replace(hour=hour, minute=minute))
|
||||
)
|
||||
slot_time = datetime.combine(check_date, datetime.min.time().replace(hour=hour, minute=minute))
|
||||
slot_time = slot_time.replace(tzinfo=tzinfo)
|
||||
|
||||
# Skip if in the past
|
||||
if slot_time <= now:
|
||||
continue
|
||||
|
||||
# Skip if slot already occupied
|
||||
if slot_time in existing_times:
|
||||
continue
|
||||
|
||||
# Count existing scheduled/published content for this day/week/month
|
||||
day_start = timezone.make_aware(datetime.combine(check_date, datetime.min.time()))
|
||||
day_end = day_start + timedelta(days=1)
|
||||
existing_day_count = Content.objects.filter(
|
||||
site=site,
|
||||
site_status__in=['scheduled', 'publishing', 'published'],
|
||||
scheduled_publish_at__gte=day_start,
|
||||
scheduled_publish_at__lt=day_end
|
||||
).count()
|
||||
|
||||
week_start = day_start - timedelta(days=day_start.weekday())
|
||||
week_end = week_start + timedelta(days=7)
|
||||
existing_week_count = Content.objects.filter(
|
||||
site=site,
|
||||
site_status__in=['scheduled', 'publishing', 'published'],
|
||||
scheduled_publish_at__gte=week_start,
|
||||
scheduled_publish_at__lt=week_end
|
||||
).count()
|
||||
|
||||
month_start = day_start.replace(day=1)
|
||||
if month_start.month == 12:
|
||||
next_month_start = month_start.replace(year=month_start.year + 1, month=1)
|
||||
else:
|
||||
next_month_start = month_start.replace(month=month_start.month + 1)
|
||||
existing_month_count = Content.objects.filter(
|
||||
site=site,
|
||||
site_status__in=['scheduled', 'publishing', 'published'],
|
||||
scheduled_publish_at__gte=month_start,
|
||||
scheduled_publish_at__lt=next_month_start
|
||||
).count()
|
||||
|
||||
# Check daily limit
|
||||
day_key = check_date.isoformat()
|
||||
slots_this_day = slots_per_day.get(day_key, 0)
|
||||
if daily_limit and (daily_count + slots_this_day) >= daily_limit:
|
||||
if daily_limit and (existing_day_count + slots_this_day) >= daily_limit:
|
||||
continue
|
||||
|
||||
# Check weekly limit
|
||||
slot_week_start = slot_time - timedelta(days=slot_time.weekday())
|
||||
if slot_week_start.date() == week_start.date():
|
||||
scheduled_in_week = weekly_count + len([s for s in slots if s >= week_start])
|
||||
if weekly_limit and scheduled_in_week >= weekly_limit:
|
||||
continue
|
||||
scheduled_in_week = existing_week_count + len([
|
||||
s for s in slots if s >= week_start and s < week_end
|
||||
])
|
||||
if weekly_limit and scheduled_in_week >= weekly_limit:
|
||||
continue
|
||||
|
||||
# Check monthly limit
|
||||
if slot_time.month == now.month and slot_time.year == now.year:
|
||||
scheduled_in_month = monthly_count + len([s for s in slots if s.month == now.month])
|
||||
if monthly_limit and scheduled_in_month >= monthly_limit:
|
||||
continue
|
||||
scheduled_in_month = existing_month_count + len([
|
||||
s for s in slots if s >= month_start and s < next_month_start
|
||||
])
|
||||
if monthly_limit and scheduled_in_month >= monthly_limit:
|
||||
continue
|
||||
|
||||
slots.append(slot_time)
|
||||
slots_per_day[day_key] = slots_per_day.get(day_key, 0) + 1
|
||||
@@ -274,8 +292,7 @@ def _generate_time_slot_slots(
|
||||
|
||||
def _generate_stagger_slots(
|
||||
settings, site, now, allowed_days,
|
||||
daily_limit, weekly_limit, monthly_limit, queue_limit,
|
||||
daily_count, weekly_count, monthly_count
|
||||
daily_limit, weekly_limit, monthly_limit, queue_limit
|
||||
) -> list:
|
||||
"""
|
||||
Generate slots spread evenly throughout the publishing window.
|
||||
@@ -305,7 +322,6 @@ def _generate_stagger_slots(
|
||||
current_date = now.date()
|
||||
slots_per_day = {}
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start = today_start - timedelta(days=now.weekday())
|
||||
|
||||
for day_offset in range(90): # Look 90 days ahead
|
||||
check_date = current_date + timedelta(days=day_offset)
|
||||
@@ -329,6 +345,37 @@ def _generate_stagger_slots(
|
||||
scheduled_publish_at__date=check_date
|
||||
).values_list('scheduled_publish_at', flat=True)
|
||||
)
|
||||
|
||||
# Count existing scheduled/published content for this day/week/month
|
||||
day_start = timezone.make_aware(datetime.combine(check_date, datetime.min.time()))
|
||||
day_end = day_start + timedelta(days=1)
|
||||
existing_day_count = Content.objects.filter(
|
||||
site=site,
|
||||
site_status__in=['scheduled', 'publishing', 'published'],
|
||||
scheduled_publish_at__gte=day_start,
|
||||
scheduled_publish_at__lt=day_end
|
||||
).count()
|
||||
|
||||
week_start = day_start - timedelta(days=day_start.weekday())
|
||||
week_end = week_start + timedelta(days=7)
|
||||
existing_week_count = Content.objects.filter(
|
||||
site=site,
|
||||
site_status__in=['scheduled', 'publishing', 'published'],
|
||||
scheduled_publish_at__gte=week_start,
|
||||
scheduled_publish_at__lt=week_end
|
||||
).count()
|
||||
|
||||
month_start = day_start.replace(day=1)
|
||||
if month_start.month == 12:
|
||||
next_month_start = month_start.replace(year=month_start.year + 1, month=1)
|
||||
else:
|
||||
next_month_start = month_start.replace(month=month_start.month + 1)
|
||||
existing_month_count = Content.objects.filter(
|
||||
site=site,
|
||||
site_status__in=['scheduled', 'publishing', 'published'],
|
||||
scheduled_publish_at__gte=month_start,
|
||||
scheduled_publish_at__lt=next_month_start
|
||||
).count()
|
||||
|
||||
# Start slot calculation
|
||||
current_slot = day_start
|
||||
@@ -343,23 +390,24 @@ def _generate_stagger_slots(
|
||||
while current_slot <= day_end:
|
||||
# Check daily limit
|
||||
slots_this_day = slots_per_day.get(day_key, 0)
|
||||
if daily_limit and (daily_count + slots_this_day) >= daily_limit:
|
||||
if daily_limit and (existing_day_count + slots_this_day) >= daily_limit:
|
||||
break # Move to next day
|
||||
|
||||
# Check weekly limit
|
||||
slot_week_start = current_slot - timedelta(days=current_slot.weekday())
|
||||
if slot_week_start.date() == week_start.date():
|
||||
scheduled_in_week = weekly_count + len([s for s in slots if s >= week_start])
|
||||
if weekly_limit and scheduled_in_week >= weekly_limit:
|
||||
current_slot += interval
|
||||
continue
|
||||
scheduled_in_week = existing_week_count + len([
|
||||
s for s in slots if s >= week_start and s < week_end
|
||||
])
|
||||
if weekly_limit and scheduled_in_week >= weekly_limit:
|
||||
current_slot += interval
|
||||
continue
|
||||
|
||||
# Check monthly limit
|
||||
if current_slot.month == now.month and current_slot.year == now.year:
|
||||
scheduled_in_month = monthly_count + len([s for s in slots if s.month == now.month])
|
||||
if monthly_limit and scheduled_in_month >= monthly_limit:
|
||||
current_slot += interval
|
||||
continue
|
||||
scheduled_in_month = existing_month_count + len([
|
||||
s for s in slots if s >= month_start and s < next_month_start
|
||||
])
|
||||
if monthly_limit and scheduled_in_month >= monthly_limit:
|
||||
current_slot += interval
|
||||
continue
|
||||
|
||||
# Avoid existing scheduled times
|
||||
if current_slot not in existing_times:
|
||||
|
||||
31
backend/igny8_core/templates/emails/team_invite.html
Normal file
31
backend/igny8_core/templates/emails/team_invite.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}You're Invited to IGNY8{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>You're invited to join {{ account_name }}</h1>
|
||||
|
||||
<p>Hi {{ invited_name }},</p>
|
||||
|
||||
<p>{{ inviter_name }} has invited you to join the <strong>{{ account_name }}</strong> workspace on IGNY8.</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ reset_url }}" class="button">Set Your Password</a>
|
||||
</p>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>Next steps:</strong>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
|
||||
<li>Click the button above to set your password</li>
|
||||
<li>Sign in to IGNY8 and start collaborating</li>
|
||||
<li>This link expires in 24 hours</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #6b7280; font-size: 14px;">{{ reset_url }}</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user