diff --git a/backend/igny8_core/api/account_views.py b/backend/igny8_core/api/account_views.py index 404b7dec..886b6af3 100644 --- a/backend/igny8_core/api/account_views.py +++ b/backend/igny8_core/api/account_views.py @@ -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', diff --git a/backend/igny8_core/auth/migrations/0032_add_account_timezone_fields.py b/backend/igny8_core/auth/migrations/0032_add_account_timezone_fields.py new file mode 100644 index 00000000..62ad63f8 --- /dev/null +++ b/backend/igny8_core/auth/migrations/0032_add_account_timezone_fields.py @@ -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), + ), + ] diff --git a/backend/igny8_core/auth/migrations/0033_add_account_timezone_history_fields.py b/backend/igny8_core/auth/migrations/0033_add_account_timezone_history_fields.py new file mode 100644 index 00000000..5024fa75 --- /dev/null +++ b/backend/igny8_core/auth/migrations/0033_add_account_timezone_history_fields.py @@ -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'), + ), + ] diff --git a/backend/igny8_core/auth/migrations/0034_add_user_phone.py b/backend/igny8_core/auth/migrations/0034_add_user_phone.py new file mode 100644 index 00000000..14d9d58d --- /dev/null +++ b/backend/igny8_core/auth/migrations/0034_add_user_phone.py @@ -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), + ), + ] diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py index 01f0b977..221ab791 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -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) diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index fd92d4da..58c2a679 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -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): diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index dfe1e158..92839a7b 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -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 diff --git a/backend/igny8_core/business/automation/views.py b/backend/igny8_core/business/automation/views.py index 98931bde..56f32652 100644 --- a/backend/igny8_core/business/automation/views.py +++ b/backend/igny8_core/business/automation/views.py @@ -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.' }) \ No newline at end of file diff --git a/backend/igny8_core/business/billing/services/email_service.py b/backend/igny8_core/business/billing/services/email_service.py index f3e9e001..2f3ec419 100644 --- a/backend/igny8_core/business/billing/services/email_service.py +++ b/backend/igny8_core/business/billing/services/email_service.py @@ -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() diff --git a/backend/igny8_core/modules/system/migrations/0026_add_team_invite_email_template.py b/backend/igny8_core/modules/system/migrations/0026_add_team_invite_email_template.py new file mode 100644 index 00000000..6444aaf4 --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0026_add_team_invite_email_template.py @@ -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), + ] diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 9c082731..374d24d3 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -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 diff --git a/backend/igny8_core/tasks/publishing_scheduler.py b/backend/igny8_core/tasks/publishing_scheduler.py index fffa6a5a..71af1380 100644 --- a/backend/igny8_core/tasks/publishing_scheduler.py +++ b/backend/igny8_core/tasks/publishing_scheduler.py @@ -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: diff --git a/backend/igny8_core/templates/emails/team_invite.html b/backend/igny8_core/templates/emails/team_invite.html new file mode 100644 index 00000000..dd7d4f14 --- /dev/null +++ b/backend/igny8_core/templates/emails/team_invite.html @@ -0,0 +1,31 @@ +{% extends "emails/base.html" %} +{% block title %}You're Invited to IGNY8{% endblock %} + +{% block content %} +
Hi {{ invited_name }},
+ +{{ inviter_name }} has invited you to join the {{ account_name }} workspace on IGNY8.
+ + + +If the button doesn't work, copy and paste this link into your browser:
+{{ reset_url }}
+ +
+Best regards,
+The {{ company_name|default:"IGNY8" }} Team
+
- Preview: {formatPreviewDate()} + Preview: {formatPreviewDate()} ({getAccountTimezone()})
Using site default schedule:
Schedule Preview:
-| - # - | -- Article - | -- Scheduled Time - | -
|---|---|---|
| - {index + 1} - | -- {item.title} - | -- {formatDate(item.scheduled_at)} - | -
Schedule Preview (first 5):
+{item.title}
+{formatDate(item.scheduled_at)}
++ +{previewData.schedule_preview.length - 5} more items will be scheduled in the next available slots. +
+ )}- Preview: {formatPreviewDate()} + Preview: {formatPreviewDate()} ({getAccountTimezone()})
UTC
+{getAccountTimezone()}
- The scheduler runs at 5 minutes past each hour. Select the hour you want your automation to run — for example, selecting 2 PM means it will run at 2:05 PM UTC. Automations only run once per day. If it has already run today, the next run will be tomorrow (or the next scheduled day for weekly/monthly). + The scheduler runs at 5 minutes past each hour. Select the hour you want your automation to run — for example, selecting 2 PM means it will run at 2:05 PM {getAccountTimezone()}. Automations only run once per day. If it has already run today, the next run will be tomorrow (or the next scheduled day for weekly/monthly).
diff --git a/frontend/src/pages/Writer/Approved.tsx b/frontend/src/pages/Writer/Approved.tsx index d53f2c98..4deaffb8 100644 --- a/frontend/src/pages/Writer/Approved.tsx +++ b/frontend/src/pages/Writer/Approved.tsx @@ -474,7 +474,7 @@ export default function Approved() { // Open site settings in new tab const handleOpenSiteSettings = useCallback(() => { if (activeSite) { - window.open(`/sites/${activeSite.id}/settings?tab=publishing`, '_blank'); + window.open(`/sites/${activeSite.id}/settings?tab=automation`, '_blank'); } }, [activeSite]); @@ -630,13 +630,16 @@ export default function Approved() { if (action === 'bulk_publish_site') { await handleBulkPublishToSite(ids); } else if (action === 'bulk_schedule_manual') { - // Manual bulk scheduling (same time for all) - handleBulkScheduleManual(ids); + // Manual bulk scheduling (same time for all) via modal + const numericIds = ids.map(id => parseInt(id)); + const items = content.filter(item => numericIds.includes(item.id)); + setBulkScheduleItems(items); + setShowBulkScheduleModal(true); } else if (action === 'bulk_schedule_defaults') { // Schedule with site defaults handleBulkScheduleWithDefaults(ids); } - }, [handleBulkPublishToSite, handleBulkScheduleManual, handleBulkScheduleWithDefaults]); + }, [handleBulkPublishToSite, handleBulkScheduleWithDefaults, content]); // Bulk status update handler const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => { diff --git a/frontend/src/pages/account/AccountSettingsPage.tsx b/frontend/src/pages/account/AccountSettingsPage.tsx index 7d13d90f..a17783f6 100644 --- a/frontend/src/pages/account/AccountSettingsPage.tsx +++ b/frontend/src/pages/account/AccountSettingsPage.tsx @@ -20,6 +20,8 @@ import { Modal } from '../../components/ui/modal'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { useAuthStore } from '../../store/authStore'; import { usePageLoading } from '../../context/PageLoadingContext'; +import { TIMEZONE_OPTIONS, getTimezoneForCountry } from '../../constants/timezones'; +import { fetchCountries } from '../../utils/countries'; import { getAccountSettings, updateAccountSettings, @@ -27,6 +29,7 @@ import { inviteTeamMember, removeTeamMember, getUserProfile, + updateUserProfile, changePassword, type AccountSettings, type TeamMember, @@ -40,6 +43,8 @@ export default function AccountSettingsPage() { const [savingProfile, setSavingProfile] = useState(false); const [error, setError] = useState