Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions backend/authentication/migrations/0002_customuser_role.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.0.2 on 2025-07-06 14:24

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("authentication", "0001_initial"),
]

operations = [
migrations.AddField(
model_name="customuser",
name="role",
field=models.CharField(
choices=[
("admin", "Admin"),
("user", "User"),
("guest", "Guest"),
("moderator", "Moderator"),
("superadmin", "Superadmin"),
],
default="user",
max_length=20,
),
),
]
8 changes: 8 additions & 0 deletions backend/authentication/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True)
is_active = models.BooleanField(default=False)
is_staff = models.BooleanField(default=False)
ROLE_CHOICES = [
("admin", "Admin"),
("user", "User"),
("guest", "Guest"),
("moderator", "Moderator"),
("superadmin", "Superadmin"),
]
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default="user")

objects = CustomUserManager()

Expand Down
50 changes: 41 additions & 9 deletions backend/backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,21 @@
"corsheaders",
"rest_framework",
"nested_admin",
"django_crontab",
"authentication",
"chat",
"gpt",
]

MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
]

ROOT_URLCONF = "backend.urls"
Expand All @@ -84,12 +85,21 @@
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases

DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
# Use PostgreSQL in production, SQLite in development
if os.environ.get('DATABASE_URL'):
# Production: PostgreSQL
import dj_database_url
DATABASES = {
'default': dj_database_url.parse(os.environ['DATABASE_URL'])
}
else:
# Development: SQLite
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
}

# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
Expand All @@ -113,6 +123,7 @@
AUTH_USER_MODEL = "authentication.CustomUser"

AUTHENTICATION_BACKENDS = [
"authentication.backends.EmailBackend",
"django.contrib.auth.backends.ModelBackend",
]

Expand All @@ -132,20 +143,41 @@
STATIC_ROOT = BASE_DIR / "static"
STATIC_URL = "/static/"

MEDIA_ROOT = BASE_DIR / 'media'
MEDIA_URL = '/media/'

# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

CORS_ALLOWED_ORIGINS = [
FRONTEND_URL,
"http://localhost:3000",
"http://127.0.0.1:3000",
]
CORS_ALLOW_CREDENTIALS = True

CSRF_TRUSTED_ORIGINS = [
FRONTEND_URL,
"http://localhost:3000",
"http://127.0.0.1:3000",
]

SESSION_COOKIE_SECURE = False
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SECURE = False
CSRF_COOKIE_SAMESITE = "Lax"

# Crontab configuration for scheduled tasks
CRONJOBS = [
# Run cleanup every day at 2:00 AM
('0 2 * * *', 'django.core.management.call_command', ['cleanup_old_conversations', '--days=30', '--deleted-only', '--force']),
# Run cleanup for very old conversations every Sunday at 3:00 AM
('0 3 * * 0', 'django.core.management.call_command', ['cleanup_old_conversations', '--days=90', '--force']),
]

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "None"
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10,
}
4 changes: 3 additions & 1 deletion backend/chat/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ def queryset(self, request, queryset):
class ConversationAdmin(NestedModelAdmin):
actions = ["undelete_selected", "soft_delete_selected"]
inlines = [VersionInline]
list_display = ("title", "id", "created_at", "modified_at", "deleted_at", "version_count", "is_deleted", "user")
list_display = ("title", "summary", "id", "created_at", "modified_at", "deleted_at", "version_count", "is_deleted", "user")
list_filter = (DeletedListFilter,)
ordering = ("-modified_at",)
readonly_fields = ("summary",)
search_fields = ("title", "summary")

def undelete_selected(self, request, queryset):
queryset.update(deleted_at=None)
Expand Down
109 changes: 109 additions & 0 deletions backend/chat/management/commands/cleanup_old_conversations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""
Django management command to clean up old conversations.
"""

import os
from datetime import timedelta
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from django.db import transaction
from chat.models import Conversation, Message, Version
from django.db import models


class Command(BaseCommand):
help = 'Clean up old conversations based on age and deletion status'

def add_arguments(self, parser):
"""Add command line arguments."""
parser.add_argument(
'--days',
type=int,
default=30,
help='Number of days after which conversations are considered old (default: 30)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be deleted without actually deleting'
)
parser.add_argument(
'--force',
action='store_true',
help='Force deletion without confirmation'
)
parser.add_argument(
'--deleted-only',
action='store_true',
help='Only clean up conversations that are already soft-deleted'
)

def handle(self, *args, **options):
"""Execute the cleanup command."""
days = options['days']
dry_run = options['dry_run']
force = options['force']
deleted_only = options['deleted_only']

cutoff_date = timezone.now() - timedelta(days=days)

self.stdout.write(f'Starting cleanup of conversations older than {days} days...')
self.stdout.write(f'Cutoff date: {cutoff_date}')

# Build the query
if deleted_only:
query = Conversation.objects.filter(deleted_at__isnull=False, modified_at__lt=cutoff_date)
self.stdout.write('Only cleaning up soft-deleted conversations')
else:
query = Conversation.objects.filter(modified_at__lt=cutoff_date)
self.stdout.write('Cleaning up all conversations older than cutoff (regardless of deletion status)')

conversations_to_delete = query.count()

if conversations_to_delete == 0:
self.stdout.write(
self.style.SUCCESS('No conversations found to clean up')
)
return

self.stdout.write(f'Found {conversations_to_delete} conversations to clean up')

if dry_run:
self.stdout.write('DRY RUN - No changes will be made')
for conversation in query[:5]: # Show first 5 as examples
self.stdout.write(f' - {conversation.title} (modified: {conversation.modified_at})')
if conversations_to_delete > 5:
self.stdout.write(f' ... and {conversations_to_delete - 5} more')
return

if not force:
confirm = input(f'\nAre you sure you want to delete {conversations_to_delete} conversations? (yes/no): ')
if confirm.lower() != 'yes':
self.stdout.write('Cleanup cancelled')
return

# Perform the cleanup
try:
with transaction.atomic():
deleted_count = 0
for conversation in query.iterator():
# Delete related objects first
Message.objects.filter(version__conversation=conversation).delete()
Version.objects.filter(conversation=conversation).delete()
conversation.delete()
deleted_count += 1

if deleted_count % 100 == 0:
self.stdout.write(f'Processed {deleted_count} conversations...')

self.stdout.write(
self.style.SUCCESS(
f'Successfully cleaned up {deleted_count} conversations'
)
)

except Exception as e:
self.stdout.write(
self.style.ERROR(f'Error during cleanup: {e}')
)
raise CommandError(f'Cleanup failed: {e}')
122 changes: 122 additions & 0 deletions backend/chat/management/commands/migrate_to_postgresql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""
Django management command to migrate data from SQLite to PostgreSQL.
"""

import os
import json
from django.core.management.base import BaseCommand, CommandError
from django.core.management import call_command
from django.conf import settings
from django.db import connections
from django.utils import timezone


class Command(BaseCommand):
help = 'Migrate data from SQLite to PostgreSQL'

def add_arguments(self, parser):
"""Add command line arguments."""
parser.add_argument(
'--backup',
action='store_true',
help='Create a backup of current data before migration'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be migrated without actually migrating'
)

def handle(self, *args, **options):
"""Execute the migration command."""
backup = options['backup']
dry_run = options['dry_run']

self.stdout.write('Starting SQLite to PostgreSQL migration...')

# Check if we're currently using SQLite
current_db = settings.DATABASES['default']['ENGINE']
if 'sqlite' not in current_db.lower():
self.stdout.write(
self.style.WARNING('Not currently using SQLite. Migration not needed.')
)
return

if backup:
self.stdout.write('Creating backup of current data...')
self._create_backup()

if dry_run:
self.stdout.write('DRY RUN - Would perform the following steps:')
self.stdout.write('1. Create PostgreSQL database')
self.stdout.write('2. Run migrations on PostgreSQL')
self.stdout.write('3. Export data from SQLite')
self.stdout.write('4. Import data to PostgreSQL')
return

try:
# Step 1: Create PostgreSQL database (user needs to do this manually)
self.stdout.write(
self.style.WARNING(
'Please ensure PostgreSQL is running and create a database.'
)
)
self.stdout.write(
'Set the DATABASE_URL environment variable to point to your PostgreSQL database.'
)

# Step 2: Run migrations on PostgreSQL
self.stdout.write('Running migrations on PostgreSQL...')
call_command('migrate', verbosity=0)

# Step 3: Export data from SQLite
self.stdout.write('Exporting data from SQLite...')
self._export_data()

# Step 4: Import data to PostgreSQL
self.stdout.write('Importing data to PostgreSQL...')
self._import_data()

self.stdout.write(
self.style.SUCCESS('Migration completed successfully!')
)

except Exception as e:
self.stdout.write(
self.style.ERROR(f'Migration failed: {e}')
)
raise CommandError(f'Migration failed: {e}')

def _create_backup(self):
"""Create a backup of current data."""
backup_dir = os.path.join(settings.BASE_DIR, 'backups')
os.makedirs(backup_dir, exist_ok=True)

backup_file = os.path.join(backup_dir, f'backup_{timezone.now().strftime("%Y%m%d_%H%M%S")}.json')

# Export all data to JSON
with open(backup_file, 'w') as f:
call_command('dumpdata', stdout=f, verbosity=0)

self.stdout.write(f'Backup created: {backup_file}')

def _export_data(self):
"""Export data from SQLite."""
export_file = os.path.join(settings.BASE_DIR, 'data_export.json')

with open(export_file, 'w') as f:
call_command('dumpdata', stdout=f, verbosity=0)

self.stdout.write(f'Data exported to: {export_file}')

def _import_data(self):
"""Import data to PostgreSQL."""
export_file = os.path.join(settings.BASE_DIR, 'data_export.json')

if not os.path.exists(export_file):
raise CommandError('Export file not found. Run export first.')

with open(export_file, 'r') as f:
call_command('loaddata', export_file, verbosity=0)

self.stdout.write('Data imported successfully')
Loading