first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 22:23:47 +03:00
commit 3de0ca1fb5
167 changed files with 5068 additions and 0 deletions

35
.env Normal file
View File

@@ -0,0 +1,35 @@
# Environment variables for MySQL database connection
MYSQL_DATABASE=dj_beyhan
MYSQL_USER=dj_beyhan
MYSQL_PASSWORD=gg7678290
MYSQL_HOST=10.80.80.70
MYSQL_PORT=3306
# Celery and Redis settings
REDIS_URL=redis://default:gg7678290@10.80.80.70:6379/4
CELERY_BROKER_URL=redis://default:gg7678290@10.80.80.70:6379/4
CELERY_RESULT_BACKEND=django-db
# Social Auth (Google)
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=your-google-oauth2-key
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=your-google-oauth2-secret
# Social Auth (GitHub)
SOCIAL_AUTH_GITHUB_KEY=your-github-key
SOCIAL_AUTH_GITHUB_SECRET=your-github-secret
# Email Settings (Optional)
EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST=10.80.80.70
EMAIL_PORT=1025
EMAIL_HOST_USER=''
EMAIL_HOST_PASSWORD=''
EMAIL_USE_TLS=False
EMAIL_USE_SSL=False
DEFAULT_FROM_EMAIL='noreply@localhost'
# Django settings
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,web_beyhan
SECRET_KEY=django-insecure-FxIJkTCbCfj9VRywq1beYkfHqsbIB9RLqH7TxqyQJhvtceB9m8sfv04j15oHw2q0
DEBUG=True

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Python 3.14.2 base image kullan
FROM python:3.14.2-slim
# Çalışma ortamı değişkenlerini ayarla
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Çalışma dizinini oluştur
WORKDIR /app
# Sistem bağımlılıklarını yükle (PostgreSQL ve diğer gerekli paketler için)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
postgresql-client \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Python bağımlılıklarını kopyala ve yükle
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Proje dosyalarını kopyala
COPY . .
# Static dosyaları topla
RUN python manage.py collectstatic --noinput --clear || true
# Media ve staticfiles dizinlerini oluştur
RUN mkdir -p /app/media /app/staticfiles
# Port 8000'i aç
EXPOSE 8000
# Entrypoint scriptini çalıştırılabilir yap
RUN chmod +x /app/entrypoint.sh || true
# Entrypoint ve varsayılan komut
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

0
accounts/__init__.py Normal file
View File

37
accounts/admin.py Normal file
View File

@@ -0,0 +1,37 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.utils.translation import gettext_lazy as _
from .models import CustomUser
@admin.register(CustomUser)
class CustomUserAdmin(BaseUserAdmin):
"""
Custom admin panel configuration for CustomUser model.
"""
# Fields to display in the user list
list_display = ('email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined')
list_filter = ('is_staff', 'is_superuser', 'is_active', 'date_joined')
search_fields = ('email', 'first_name', 'last_name')
ordering = ('-date_joined',)
# Fields to display on the user detail/edit page
fieldsets = (
(None, {'fields': ('email', 'password')}),
(_('Personal info'), {'fields': ('first_name', 'last_name')}),
(_('Permissions'), {
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'),
}),
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
)
# Fields to display when creating a new user
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('email', 'password1', 'password2', 'first_name', 'last_name', 'is_staff', 'is_active'),
}),
)
readonly_fields = ('date_joined', 'last_login')

5
accounts/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = 'accounts'

27
accounts/middleware.py Normal file
View File

@@ -0,0 +1,27 @@
"""
Custom middleware for social authentication.
"""
class SocialAuthExceptionMiddleware:
"""
Middleware to handle social auth exceptions and redirect properly.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
return response
def process_exception(self, request, exception):
"""Handle social auth exceptions."""
from social_core.exceptions import AuthException
from django.http import HttpResponseRedirect
if isinstance(exception, AuthException):
return HttpResponseRedirect(f'/api/v1/auth/social/error/?error={str(exception)}')
return None

View File

@@ -0,0 +1,37 @@
# Generated by Django 6.0.2 on 2026-02-13 20:29
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('email', models.EmailField(error_messages={'unique': 'A user with that email already exists.'}, max_length=254, unique=True, verbose_name='email address')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
},
),
]

View File

103
accounts/models.py Normal file
View File

@@ -0,0 +1,103 @@
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
class CustomUserManager(BaseUserManager):
"""
Custom user manager where email is the unique identifier
for authentication instead of username.
"""
def create_user(self, email, password=None, **extra_fields):
"""
Create and save a regular user with the given email and password.
"""
if not email:
raise ValueError(_('The Email field must be set'))
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, email, password=None, **extra_fields):
"""
Create and save a SuperUser with the given email and password.
"""
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
extra_fields.setdefault('is_active', True)
if extra_fields.get('is_staff') is not True:
raise ValueError(_('Superuser must have is_staff=True.'))
if extra_fields.get('is_superuser') is not True:
raise ValueError(_('Superuser must have is_superuser=True.'))
return self.create_user(email, password, **extra_fields)
class CustomUser(AbstractBaseUser, PermissionsMixin):
"""
Custom user model where email is used instead of username.
Fields:
- email: unique email address (used for login)
- first_name: user's first name
- last_name: user's last name
- is_staff: designates whether user can log into admin site
- is_active: designates whether user account is active
- date_joined: when the user account was created
"""
email = models.EmailField(
_('email address'),
unique=True,
error_messages={
'unique': _("A user with that email already exists."),
}
)
first_name = models.CharField(_('first name'), max_length=150, blank=True)
last_name = models.CharField(_('last name'), max_length=150, blank=True)
is_staff = models.BooleanField(
_('staff status'),
default=False,
help_text=_('Designates whether the user can log into this admin site.'),
)
is_active = models.BooleanField(
_('active'),
default=True,
help_text=_(
'Designates whether this user should be treated as active. '
'Unselect this instead of deleting accounts.'
),
)
date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
# Specify that we use email as the username field
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = [] # Email is already required by USERNAME_FIELD
objects = CustomUserManager()
class Meta:
verbose_name = _('user')
verbose_name_plural = _('users')
def __str__(self):
return self.email
def get_full_name(self):
"""
Return the first_name plus the last_name, with a space in between.
"""
full_name = f'{self.first_name} {self.last_name}'
return full_name.strip()
def get_short_name(self):
"""
Return the short name for the user.
"""
return self.first_name

19
accounts/pipeline.py Normal file
View File

@@ -0,0 +1,19 @@
"""
Custom pipeline functions for Python Social Auth.
These functions are called during the social authentication process.
"""
def activate_user(strategy, details, user=None, *args, **kwargs):
"""
Custom pipeline step to ensure social auth users are active.
This ensures that users who register via social login don't need
email activation - they are automatically activated since the social
provider has already verified their email.
"""
if user and not user.is_active:
user.is_active = True
user.save(update_fields=['is_active'])
return {'user': user}

74
accounts/serializers.py Normal file
View File

@@ -0,0 +1,74 @@
from rest_framework import serializers
from djoser.serializers import UserCreateSerializer as BaseUserCreateSerializer
from djoser.serializers import UserSerializer as BaseUserSerializer
from .models import CustomUser
class CustomUserCreateSerializer(BaseUserCreateSerializer):
"""
Custom serializer for user registration.
Sets is_active=False by default so users must activate via email.
"""
class Meta(BaseUserCreateSerializer.Meta):
model = CustomUser
fields = ('id', 'email', 'password', 're_password', 'first_name', 'last_name')
def create(self, validated_data):
"""
Override create to ensure is_active=False for email/password registrations.
Social auth users will have is_active=True set via pipeline.
"""
# Remove re_password as it's only for validation
validated_data.pop('re_password', None)
# Create user with is_active=False
user = CustomUser.objects.create_user(
email=validated_data['email'],
password=validated_data['password'],
first_name=validated_data.get('first_name', ''),
last_name=validated_data.get('last_name', ''),
is_active=False # Requires email activation
)
return user
class CustomUserSerializer(BaseUserSerializer):
"""
Serializer for user details.
Used for current user endpoint and user profile.
"""
class Meta(BaseUserSerializer.Meta):
model = CustomUser
fields = ('id', 'email', 'first_name', 'last_name', 'is_active', 'date_joined')
read_only_fields = ('id', 'email', 'is_active', 'date_joined')
class SocialLoginSerializer(serializers.Serializer):
"""
Serializer for social authentication.
Accepts provider name and access_token from frontend.
"""
provider = serializers.ChoiceField(
choices=['google-oauth2', 'github', 'facebook'],
help_text="Social auth provider name"
)
access_token = serializers.CharField(
help_text="Access token from the social provider"
)
id_token = serializers.CharField(
required=False,
allow_blank=True,
help_text="ID token (optional, used by some providers like Google)"
)
def validate_provider(self, value):
"""Validate that the provider is supported."""
valid_providers = ['google-oauth2', 'github', 'facebook']
if value not in valid_providers:
raise serializers.ValidationError(
f"Invalid provider. Must be one of: {', '.join(valid_providers)}"
)
return value

3
accounts/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

51
accounts/urls.py Normal file
View File

@@ -0,0 +1,51 @@
from django.urls import path, include
from .views import SocialLoginView, SocialAuthCallbackView, SocialAuthSuccessView
urlpatterns = [
# Python Social Auth URLs (MUST BE FIRST for OAuth redirect flow)
# /api/v1/social/login/github/ - GET: Start GitHub OAuth
# /api/v1/social/login/google-oauth2/ - GET: Start Google OAuth
# /api/v1/social/complete/github/ - GET: GitHub callback (handled by social-auth)
# /api/v1/social/complete/google-oauth2/ - GET: Google callback (handled by social-auth)
path('social/', include('social_django.urls', namespace='social')),
# SPA Test Page (Main app)
path('spa/', lambda request:
__import__('django.shortcuts').shortcuts.render(
request, 'spa_test/index.html'
), name='spa-test'),
# SPA Activation Page (Frontend route for email links)
path('spa/activate/<str:uid>/<str:token>/', lambda request, uid, token:
__import__('django.shortcuts').shortcuts.render(
request, 'spa_test/activate.html', {'uid': uid, 'token': token}
), name='spa-activate'),
# Django REST Framework browsable API auth
path('api-auth/', include('rest_framework.urls')),
# Djoser endpoints (registration, activation, etc.)
# /api/v1/auth/users/ - POST: Register new user
# /api/v1/auth/users/activation/ - POST: Activate account with uid/token
# /api/v1/auth/users/me/ - GET: Get current user info
# /api/v1/auth/users/resend_activation/ - POST: Resend activation email
path('auth/', include('djoser.urls')),
# Djoser JWT endpoints
# /api/v1/auth/jwt/create/ - POST: Login (get JWT tokens)
# /api/v1/auth/jwt/refresh/ - POST: Refresh access token
# /api/v1/auth/jwt/verify/ - POST: Verify token
path('auth/', include('djoser.urls.jwt')),
# Social authentication endpoints (Token-based - for mobile/SPA)
# /api/v1/auth/social/google-oauth2/ - POST: Login with Google (requires access_token)
# /api/v1/auth/social/github/ - POST: Login with GitHub (requires access_token)
# /api/v1/auth/social/facebook/ - POST: Login with Facebook (requires access_token)
path('auth/social/<str:provider>/', SocialLoginView.as_view(), name='social-login'),
# OAuth callback handler (after social-auth completes)
path('auth/social/callback/', SocialAuthCallbackView.as_view(), name='social-callback'),
# Success/Error pages
path('auth/social/success/', SocialAuthSuccessView.as_view(), name='social-success'),
]

271
accounts/views.py Normal file
View File

@@ -0,0 +1,271 @@
from django.shortcuts import redirect
from django.views import View
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.tokens import RefreshToken
from social_django.utils import load_strategy, load_backend
from social_core.backends.oauth import BaseOAuth2
from social_core.exceptions import AuthException, AuthForbidden
from .serializers import SocialLoginSerializer, CustomUserSerializer
import json
class SocialLoginView(APIView):
"""
Social authentication endpoint.
Accepts access_token from social provider and returns JWT tokens.
POST /api/v1/auth/social/<provider>/
Body: { "access_token": "..." }
Supported providers: google-oauth2, github, facebook
"""
permission_classes = [AllowAny]
serializer_class = SocialLoginSerializer
def post(self, request, provider):
"""
Authenticate user with social provider token.
"""
# Validate provider
valid_providers = ['google-oauth2', 'github', 'facebook']
if provider not in valid_providers:
return Response(
{'error': f'Invalid provider. Must be one of: {", ".join(valid_providers)}'},
status=status.HTTP_400_BAD_REQUEST
)
# Get access_token from request
access_token = request.data.get('access_token')
id_token = request.data.get('id_token', None)
if not access_token:
return Response(
{'error': 'access_token is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
# Load social auth strategy and backend
strategy = load_strategy(request)
backend = load_backend(
strategy=strategy,
name=provider,
redirect_uri=None
)
# Verify token and get user
if isinstance(backend, BaseOAuth2):
# For OAuth2 providers, use access_token to get user info
user = backend.do_auth(access_token)
else:
return Response(
{'error': 'Unsupported authentication backend'},
status=status.HTTP_400_BAD_REQUEST
)
if not user:
return Response(
{'error': 'Authentication failed. Invalid token.'},
status=status.HTTP_401_UNAUTHORIZED
)
# Check if user is active
if not user.is_active:
# This shouldn't happen for social auth users, but just in case
user.is_active = True
user.save(update_fields=['is_active'])
# Generate JWT tokens
refresh = RefreshToken.for_user(user)
# Serialize user data
user_serializer = CustomUserSerializer(user)
return Response({
'access': str(refresh.access_token),
'refresh': str(refresh),
'user': user_serializer.data
}, status=status.HTTP_200_OK)
except AuthForbidden:
return Response(
{'error': 'Authentication forbidden. Email not provided by provider or permission denied.'},
status=status.HTTP_403_FORBIDDEN
)
except AuthException as e:
return Response(
{'error': f'Authentication error: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
return Response(
{'error': f'An error occurred during authentication: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class SocialAuthCallbackView(View):
"""
Callback view for OAuth flow completion.
After successful authentication, redirects to frontend with tokens.
"""
permission_classes = [AllowAny]
authentication_classes = [] # No authentication required for callback
def get(self, request):
"""Handle OAuth callback and redirect to frontend with JWT tokens."""
from django.http import HttpResponseRedirect
# Get the authenticated user from the session
user = request.user
if user and user.is_authenticated:
# Generate JWT tokens
refresh = RefreshToken.for_user(user)
# Redirect to SPA with tokens (for testing)
redirect_url = f"/api/v1/spa/?access={str(refresh.access_token)}&refresh={str(refresh)}"
print(f"[OAuth Callback] Redirecting to: {redirect_url}")
return HttpResponseRedirect(redirect_url)
else:
# Authentication failed
return HttpResponseRedirect("/api/v1/auth/social/error/?error=authentication_failed")
class SocialAuthSuccessView(APIView):
"""
Success page after social authentication.
Displays tokens for testing purposes.
"""
permission_classes = [AllowAny]
authentication_classes = [] # No authentication required
def get(self, request):
"""Display success page with tokens."""
access_token = request.GET.get('access', '')
refresh_token = request.GET.get('refresh', '')
# Also check if user is in session
if not access_token and request.user.is_authenticated:
refresh = RefreshToken.for_user(request.user)
access_token = str(refresh.access_token)
refresh_token = str(refresh)
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<style>
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}}
.container {{
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
max-width: 600px;
width: 100%;
}}
h1 {{
color: #28a745;
text-align: center;
margin-bottom: 20px;
}}
.success-icon {{
text-align: center;
font-size: 64px;
margin-bottom: 20px;
}}
.token-box {{
background: #f8f9fa;
border: 2px solid #e1e4e8;
border-radius: 8px;
padding: 15px;
margin: 15px 0;
word-break: break-all;
}}
.token-label {{
font-weight: bold;
color: #333;
margin-bottom: 5px;
}}
.token-value {{
font-family: 'Courier New', monospace;
font-size: 12px;
color: #666;
background: white;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}}
.btn {{
display: block;
width: 100%;
padding: 12px;
margin-top: 20px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}}
.btn:hover {{
background: #5568d3;
}}
</style>
</head>
<body>
<div class="container">
<div class="success-icon">✅</div>
<h1>Authentication Successful!</h1>
<p style="text-align: center; color: #666; margin-bottom: 30px;">
You have successfully authenticated with your social account.
</p>
<div class="token-box">
<div class="token-label">Access Token:</div>
<div class="token-value" id="accessToken">{access_token}</div>
</div>
<div class="token-box">
<div class="token-label">Refresh Token:</div>
<div class="token-value" id="refreshToken">{refresh_token}</div>
</div>
<button class="btn" onclick="copyTokens()">Copy Tokens to Clipboard</button>
<button class="btn" onclick="window.close()" style="background: #6c757d;">Close Window</button>
</div>
<script>
function copyTokens() {{
const tokens = {{
access: "{access_token}",
refresh: "{refresh_token}"
}};
navigator.clipboard.writeText(JSON.stringify(tokens, null, 2))
.then(() => alert('Tokens copied to clipboard!'))
.catch(err => alert('Failed to copy: ' + err));
}}
</script>
</body>
</html>
"""
from django.http import HttpResponse
return HttpResponse(html_content)

2
backup/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
default_app_config = 'backup.apps.BackupConfig'

368
backup/admin.py Normal file
View File

@@ -0,0 +1,368 @@
from django.contrib import admin
from django.utils.html import format_html
from django.contrib import messages
from django.utils import timezone
from django.http import FileResponse, HttpResponse
from django.shortcuts import get_object_or_404, render, redirect
from django.urls import path, reverse
from django.conf import settings
import os
from datetime import datetime
from .models import DatabaseBackup
from .views import BackupManager
@admin.register(DatabaseBackup)
class DatabaseBackupAdmin(admin.ModelAdmin):
list_display = ['name', 'status_badge', 'backup_type', 'file_size_display', 'download_link', 'created_by', 'created_at', 'completed_at']
list_filter = ['status', 'backup_type', 'created_at']
search_fields = ['name', 'notes', 'error_message']
readonly_fields = ['file_path', 'file_size', 'status', 'created_by', 'created_at', 'completed_at', 'error_message', 'file_size_display_field']
fieldsets = (
('Temel Bilgiler', {
'fields': ('name', 'backup_type', 'status', 'notes')
}),
('Yedek Dosya Bilgileri', {
'fields': ('file_path', 'file_size_display_field')
}),
('Zaman Bilgileri', {
'fields': ('created_by', 'created_at', 'completed_at')
}),
('Hata Bilgileri', {
'fields': ('error_message',),
'classes': ('collapse',)
}),
)
actions = ['create_new_backup', 'restore_selected_backup', 'download_backup', 'delete_backup_files']
def status_badge(self, obj):
"""Durum için renkli badge gösterir"""
colors = {
'pending': '#FFA500',
'in_progress': '#2196F3',
'completed': '#4CAF50',
'failed': '#F44336',
}
color = colors.get(obj.status, '#999')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px; font-weight: bold;">{}</span>',
color,
obj.get_status_display()
)
status_badge.short_description = 'Durum'
def file_size_display(self, obj):
"""Dosya boyutunu gösterir"""
return obj.get_file_size_display()
file_size_display.short_description = 'Dosya Boyutu'
def file_size_display_field(self, obj):
"""Read-only field için dosya boyutu"""
return obj.get_file_size_display()
file_size_display_field.short_description = 'Dosya Boyutu'
def download_link(self, obj):
"""İndir butonu gösterir"""
if obj.file_path and obj.status == 'completed' and os.path.isfile(obj.file_path):
url = f'/admin/backup/databasebackup/{obj.pk}/download/'
return format_html(
'<a href="{}" class="button" style="background-color: #4CAF50; color: white; padding: 5px 10px; '
'text-decoration: none; border-radius: 3px; display: inline-block;">📥 İndir</a>',
url
)
return format_html('<span style="color: {};">-</span>', '#999')
download_link.short_description = 'İndir'
def get_urls(self):
"""Admin için özel URL'ler ekler"""
urls = super().get_urls()
custom_urls = [
path('create-backup/', self.admin_site.admin_view(self.create_backup_view), name='backup_create'),
path('upload-backup/', self.admin_site.admin_view(self.upload_backup_view), name='backup_upload'),
path('<int:backup_id>/download/', self.admin_site.admin_view(self.download_backup_file), name='backup_download'),
]
return custom_urls + urls
def changelist_view(self, request, extra_context=None):
"""Change list view'a ekstra context ekler"""
extra_context = extra_context or {}
extra_context['show_create_backup_button'] = True
extra_context['show_upload_backup_button'] = True
return super().changelist_view(request, extra_context=extra_context)
def create_backup_view(self, request):
"""Yeni yedek oluşturma view'i"""
from django.shortcuts import redirect
from django.urls import reverse
# Yeni bir backup objesi oluştur
timestamp = timezone.now().strftime('%Y%m%d_%H%M%S')
backup = DatabaseBackup.objects.create(
name=f"Manuel Yedek - {timezone.now().strftime('%Y-%m-%d %H:%M:%S')}",
backup_type='manual',
created_by=request.user,
status='pending'
)
# Yedekleme işlemini başlat
manager = BackupManager()
success, message = manager.create_backup(backup)
if success:
self.message_user(request, message, messages.SUCCESS)
else:
self.message_user(request, message, messages.ERROR)
# Liste sayfasına yönlendir
return redirect(reverse('admin:backup_databasebackup_changelist'))
def upload_backup_view(self, request):
"""Yedek dosyası yükleme view'i"""
if request.method == 'POST':
uploaded_file = request.FILES.get('backup_file')
backup_name = request.POST.get('backup_name', '')
if not uploaded_file:
self.message_user(request, "Lütfen bir dosya seçin", messages.ERROR)
return redirect(reverse('admin:backup_upload'))
# Dosya uzantısı kontrolü
if not uploaded_file.name.endswith('.sql'):
self.message_user(request, "Sadece .sql uzantılı dosyalar yüklenebilir", messages.ERROR)
return redirect(reverse('admin:backup_upload'))
# Dosya boyutu kontrolü (max 500MB)
max_size = 500 * 1024 * 1024 # 500MB in bytes
if uploaded_file.size > max_size:
self.message_user(request, "Dosya çok büyük. Maksimum 500MB olabilir", messages.ERROR)
return redirect(reverse('admin:backup_upload'))
try:
# Backup klasörünü kontrol et
manager = BackupManager()
backup_dir = manager.backup_dir
# Dosya adını oluştur (timestamp ekle)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
original_name = os.path.splitext(uploaded_file.name)[0]
filename = f"uploaded_{original_name}_{timestamp}.sql"
file_path = os.path.join(backup_dir, filename)
# Dosyayı kaydet
with open(file_path, 'wb+') as destination:
for chunk in uploaded_file.chunks():
destination.write(chunk)
# Veritabanı kaydı oluştur
if not backup_name:
backup_name = f"Yüklenen Yedek - {uploaded_file.name}"
backup = DatabaseBackup.objects.create(
name=backup_name,
file_path=file_path,
file_size=uploaded_file.size,
status='completed',
backup_type='manual',
created_by=request.user,
completed_at=timezone.now(),
notes=f"Dosya yüklendi: {uploaded_file.name}"
)
self.message_user(
request,
f"Yedek dosyası başarıyla yüklendi: {uploaded_file.name} ({backup.get_file_size_display()})",
messages.SUCCESS
)
return redirect(reverse('admin:backup_databasebackup_changelist'))
except Exception as e:
self.message_user(request, f"Dosya yüklenirken hata oluştu: {str(e)}", messages.ERROR)
return redirect(reverse('admin:backup_upload'))
# GET request - form göster
context = {
**self.admin_site.each_context(request),
'title': 'Yedek Dosyası Yükle',
'opts': self.model._meta,
'has_view_permission': self.has_view_permission(request),
}
return render(request, 'admin/backup/upload_backup.html', context)
def download_backup_file(self, request, backup_id):
"""Yedek dosyasını indirir"""
backup = get_object_or_404(DatabaseBackup, pk=backup_id)
if not backup.file_path:
self.message_user(request, "Yedek dosyası bulunamadı", messages.ERROR)
return HttpResponse("Dosya bulunamadı", status=404)
if not os.path.isfile(backup.file_path):
self.message_user(request, "Yedek dosyası disk üzerinde bulunamadı", messages.ERROR)
return HttpResponse("Dosya disk üzerinde bulunamadı", status=404)
# Dosyayı indir
try:
response = FileResponse(open(backup.file_path, 'rb'), content_type='application/sql')
filename = os.path.basename(backup.file_path)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
except Exception as e:
return HttpResponse(f"Dosya indirilemedi: {str(e)}", status=500)
def save_model(self, request, obj, form, change):
"""Model kaydedilirken created_by alanını otomatik doldur"""
if not change: # Yeni kayıt
obj.created_by = request.user
super().save_model(request, obj, form, change)
def create_new_backup(self, request, queryset):
"""Yeni bir yedek oluşturur"""
# Yeni bir backup objesi oluştur
timestamp = timezone.now().strftime('%Y-%m-%d %H:%M:%S')
backup = DatabaseBackup.objects.create(
name=f"Manuel Yedek - {timestamp}",
backup_type='manual',
created_by=request.user,
status='pending'
)
# Yedekleme işlemini başlat
manager = BackupManager()
success, message = manager.create_backup(backup)
if success:
self.message_user(request, message, messages.SUCCESS)
else:
self.message_user(request, message, messages.ERROR)
create_new_backup.short_description = "Yeni Yedek Oluştur"
def restore_selected_backup(self, request, queryset):
"""Seçili yedeği geri yükler"""
if queryset.count() != 1:
self.message_user(
request,
"Lütfen geri yüklemek için sadece bir yedek seçin",
messages.WARNING
)
return
backup = queryset.first()
if backup.status != 'completed':
self.message_user(
request,
"Sadece tamamlanmış yedekler geri yüklenebilir",
messages.WARNING
)
return
if not backup.file_path:
self.message_user(
request,
"Yedek dosya yolu bulunamadı",
messages.ERROR
)
return
# Restore işlemi (migration'lar da dahil)
manager = BackupManager()
success, message = manager.restore_backup(backup.file_path)
if success:
# Otomatik migration çalıştır
try:
from django.core.management import call_command
import io
call_command('migrate', '--noinput', stdout=io.StringIO(), stderr=io.StringIO())
self.message_user(request, f"{message} Migration'lar uygulandı. Sayfayı yenileyin.", messages.SUCCESS)
except:
self.message_user(request, f"{message} Sayfayı yenileyin.", messages.SUCCESS)
else:
self.message_user(request, message, messages.ERROR)
restore_selected_backup.short_description = "Seçili Yedeği Geri Yükle"
def download_backup(self, request, queryset):
"""Seçili yedeği indirir"""
if queryset.count() != 1:
self.message_user(
request,
"Lütfen indirmek için sadece bir yedek seçin",
messages.WARNING
)
return
backup = queryset.first()
if backup.status != 'completed':
self.message_user(
request,
"Sadece tamamlanmış yedekler indirilebilir",
messages.WARNING
)
return
if not backup.file_path or not os.path.isfile(backup.file_path):
self.message_user(
request,
"Yedek dosyası bulunamadı",
messages.ERROR
)
return
# Dosyayı indir
try:
response = FileResponse(open(backup.file_path, 'rb'), content_type='application/sql')
filename = os.path.basename(backup.file_path)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
except Exception as e:
self.message_user(
request,
f"Dosya indirilemedi: {str(e)}",
messages.ERROR
)
return
download_backup.short_description = "Seçili Yedeği İndir"
def delete_backup_files(self, request, queryset):
"""Seçili yedeklerin dosyalarını siler"""
deleted_count = 0
error_count = 0
manager = BackupManager()
for backup in queryset:
if backup.file_path:
success, message = manager.delete_backup_file(backup.file_path)
if success:
backup.file_path = None
backup.file_size = None
backup.save()
deleted_count += 1
else:
error_count += 1
if deleted_count > 0:
self.message_user(
request,
f"{deleted_count} yedek dosyası silindi",
messages.SUCCESS
)
if error_count > 0:
self.message_user(
request,
f"{error_count} yedek dosyası silinemedi",
messages.WARNING
)
delete_backup_files.short_description = "Yedek Dosyalarını Sil"
def has_delete_permission(self, request, obj=None):
"""Silme iznini kontrol et - Tüm admin kullanıcıları silebilir"""
return request.user.is_staff

10
backup/apps.py Normal file
View File

@@ -0,0 +1,10 @@
from django.apps import AppConfig
class BackupConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'backup'
def ready(self):
"""Uygulama hazır olduğunda sinyalleri import et"""
import backup.models # Sinyalleri kaydetmek için import et

View File

@@ -0,0 +1,38 @@
# Generated by Django 6.0 on 2025-12-22 16:52
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DatabaseBackup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Yedek Adı')),
('file_path', models.CharField(blank=True, max_length=500, null=True, verbose_name='Dosya Yolu')),
('file_size', models.BigIntegerField(blank=True, null=True, verbose_name='Dosya Boyutu (bytes)')),
('status', models.CharField(choices=[('pending', 'Bekliyor'), ('in_progress', 'İşleniyor'), ('completed', 'Tamamlandı'), ('failed', 'Başarısız')], default='pending', max_length=20, verbose_name='Durum')),
('backup_type', models.CharField(choices=[('manual', 'Manuel'), ('automatic', 'Otomatik')], default='manual', max_length=20, verbose_name='Yedek Tipi')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Tamamlanma Tarihi')),
('error_message', models.TextField(blank=True, null=True, verbose_name='Hata Mesajı')),
('notes', models.TextField(blank=True, null=True, verbose_name='Notlar')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Oluşturan')),
],
options={
'verbose_name': 'Veritabanı Yedeği',
'verbose_name_plural': 'Veritabanı Yedekleri',
'ordering': ['-created_at'],
},
),
]

View File

68
backup/models.py Normal file
View File

@@ -0,0 +1,68 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.db.models.signals import post_delete
from django.dispatch import receiver
import os
User = get_user_model()
class DatabaseBackup(models.Model):
"""Veritabanı yedekleme kayıtlarını tutar"""
STATUS_CHOICES = [
('pending', 'Bekliyor'),
('in_progress', 'İşleniyor'),
('completed', 'Tamamlandı'),
('failed', 'Başarısız'),
]
BACKUP_TYPE_CHOICES = [
('manual', 'Manuel'),
('automatic', 'Otomatik'),
]
name = models.CharField(max_length=255, verbose_name='Yedek Adı')
file_path = models.CharField(max_length=500, verbose_name='Dosya Yolu', blank=True, null=True)
file_size = models.BigIntegerField(verbose_name='Dosya Boyutu (bytes)', null=True, blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='Durum')
backup_type = models.CharField(max_length=20, choices=BACKUP_TYPE_CHOICES, default='manual', verbose_name='Yedek Tipi')
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='Oluşturan')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')
completed_at = models.DateTimeField(null=True, blank=True, verbose_name='Tamamlanma Tarihi')
error_message = models.TextField(blank=True, null=True, verbose_name='Hata Mesajı')
notes = models.TextField(blank=True, null=True, verbose_name='Notlar')
class Meta:
verbose_name = 'Veritabanı Yedeği'
verbose_name_plural = 'Veritabanı Yedekleri'
ordering = ['-created_at']
def __str__(self):
return f"{self.name} - {self.get_status_display()}"
def get_file_size_display(self):
"""Dosya boyutunu okunabilir formatta döndürür"""
if not self.file_size:
return "N/A"
size = self.file_size
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024.0:
return f"{size:.2f} {unit}"
size /= 1024.0
return f"{size:.2f} TB"
@receiver(post_delete, sender=DatabaseBackup)
def delete_backup_file(sender, instance, **kwargs):
"""
Backup kaydı silindiğinde, ilişkili fiziksel dosyayı da siler
"""
if instance.file_path and os.path.isfile(instance.file_path):
try:
os.remove(instance.file_path)
print(f"Yedek dosyası silindi: {instance.file_path}")
except Exception as e:
print(f"Dosya silinirken hata oluştu: {e}")

View File

@@ -0,0 +1,21 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls static %}
{% block object-tools-items %}
{{ block.super }}
{% if show_create_backup_button %}
<li>
<a href="{% url 'admin:backup_create' %}" class="addlink" style="background-color: #4CAF50; color: white; padding: 10px 15px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;">
🔄 Yeni Yedek Al
</a>
</li>
{% endif %}
{% if show_upload_backup_button %}
<li>
<a href="{% url 'admin:backup_upload' %}" class="addlink" style="background-color: #2196F3; color: white; padding: 10px 15px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;">
📤 Yedek Yükle
</a>
</li>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,174 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block extrahead %}
{{ block.super }}
<style>
.upload-form-container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: bold;
margin-bottom: 8px;
color: #333;
}
.form-group input[type="text"],
.form-group input[type="file"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-group input[type="file"] {
padding: 8px;
}
.help-text {
display: block;
margin-top: 5px;
font-size: 12px;
color: #666;
}
.btn-container {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #2196F3;
color: white;
}
.btn-primary:hover {
background-color: #1976D2;
}
.btn-secondary {
background-color: #757575;
color: white;
}
.btn-secondary:hover {
background-color: #616161;
}
.info-box {
background-color: #E3F2FD;
border-left: 4px solid #2196F3;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.info-box h4 {
margin-top: 0;
color: #1976D2;
}
.info-box ul {
margin: 10px 0;
padding-left: 20px;
}
</style>
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:backup_databasebackup_changelist' %}">Veritabanı Yedekleri</a>
&rsaquo; Yedek Yükle
</div>
{% endblock %}
{% block content %}
<div class="upload-form-container">
<h1>📤 Yedek Dosyası Yükle</h1>
<div class="info-box">
<h4> Bilgilendirme</h4>
<ul>
<li>Sadece <strong>.sql</strong> uzantılı dosyalar yüklenebilir</li>
<li>Maksimum dosya boyutu: <strong>500 MB</strong></li>
<li>Yüklenen dosya <code>backups/</code> klasörüne kaydedilecektir</li>
<li>Dosya otomatik olarak timestamp ile adlandırılacaktır</li>
</ul>
</div>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="form-group">
<label for="backup_name">Yedek Adı:</label>
<input type="text"
id="backup_name"
name="backup_name"
placeholder="Örn: Production Yedek - 2024-12-24"
maxlength="255">
<span class="help-text">
Boş bırakılırsa dosya adı kullanılacaktır
</span>
</div>
<div class="form-group">
<label for="backup_file">Yedek Dosyası: *</label>
<input type="file"
id="backup_file"
name="backup_file"
accept=".sql"
required>
<span class="help-text">
PostgreSQL SQL dump dosyası (.sql)
</span>
</div>
<div class="btn-container">
<button type="submit" class="btn btn-primary">
📤 Yükle
</button>
<a href="{% url 'admin:backup_databasebackup_changelist' %}" class="btn btn-secondary">
❌ İptal
</a>
</div>
</form>
</div>
<script>
// Dosya seçildiğinde boyut kontrolü
document.getElementById('backup_file').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const maxSize = 500 * 1024 * 1024; // 500MB
if (file.size > maxSize) {
alert('Dosya çok büyük! Maksimum 500MB olabilir.');
e.target.value = '';
return;
}
// Dosya adını backup_name alanına otomatik doldur (eğer boşsa)
const nameField = document.getElementById('backup_name');
if (!nameField.value) {
const fileName = file.name.replace('.sql', '');
nameField.value = 'Yüklenen Yedek - ' + fileName;
}
// Dosya boyutunu göster
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
console.log('Dosya boyutu: ' + sizeInMB + ' MB');
}
});
</script>
{% endblock %}

3
backup/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

328
backup/views.py Normal file
View File

@@ -0,0 +1,328 @@
import os
from datetime import datetime
from django.conf import settings
from django.utils import timezone
from .models import DatabaseBackup
try:
import psycopg2
from psycopg2 import sql
PSYCOPG2_AVAILABLE = True
except ImportError:
PSYCOPG2_AVAILABLE = False
class BackupManager:
"""PostgreSQL veritabanı yedekleme işlemlerini yönetir - Sadece psycopg2 kullanarak"""
def __init__(self):
self.backup_dir = os.path.join(settings.BASE_DIR, 'backups')
if not os.path.exists(self.backup_dir):
os.makedirs(self.backup_dir)
def get_db_config(self):
"""Veritabanı yapılandırmasını alır"""
db_config = settings.DATABASES['default']
return {
'dbname': db_config.get('NAME'),
'user': db_config.get('USER'),
'password': db_config.get('PASSWORD'),
'host': db_config.get('HOST', 'localhost'),
'port': db_config.get('PORT', '5432'),
}
def get_connection(self):
"""PostgreSQL bağlantısı oluşturur"""
if not PSYCOPG2_AVAILABLE:
raise Exception("psycopg2 kütüphanesi yüklü değil")
db_config = self.get_db_config()
return psycopg2.connect(
dbname=db_config['dbname'],
user=db_config['user'],
password=db_config['password'],
host=db_config['host'],
port=db_config['port']
)
#@task
def create_backup(self, backup_obj):
"""
PostgreSQL veritabanının yedeğini oluşturur
Sadece psycopg2 kullanarak SQL dump oluşturur
"""
try:
backup_obj.status = 'in_progress'
backup_obj.save()
db_config = self.get_db_config()
# Yedek dosyası adını oluştur
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_filename = f"backup_{db_config['dbname']}_{timestamp}.sql"
backup_path = os.path.join(self.backup_dir, backup_filename)
# Veritabanına bağlan
conn = self.get_connection()
cursor = conn.cursor()
with open(backup_path, 'w', encoding='utf-8') as f:
# Header
f.write("-- PostgreSQL Database Backup\n")
f.write(f"-- Database: {db_config['dbname']}\n")
f.write(f"-- Date: {datetime.now()}\n")
f.write("-- Created by Django Backup System using psycopg2\n\n")
f.write("SET client_encoding = 'UTF8';\n")
f.write("SET standard_conforming_strings = on;\n")
f.write("SET check_function_bodies = false;\n")
f.write("SET client_min_messages = warning;\n\n")
# Tüm tabloları al
cursor.execute("""
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
""")
tables = cursor.fetchall()
for (table_name,) in tables:
f.write(f"\n-- Table: {table_name}\n")
# Tablo yapısını al - kolon bilgilerini çek
cursor.execute("""
SELECT
column_name,
data_type,
character_maximum_length,
is_nullable,
column_default,
is_identity
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s
ORDER BY ordinal_position;
""", [table_name])
columns_info = cursor.fetchall()
if columns_info:
f.write(f"DROP TABLE IF EXISTS \"{table_name}\" CASCADE;\n")
f.write(f"CREATE TABLE \"{table_name}\" (\n")
col_defs = []
for col_name, data_type, max_length, is_nullable, col_default, is_identity in columns_info:
col_def = f" \"{col_name}\" "
# Serial kontrolü (Nextval veya Identity)
is_serial = False
if (col_default and 'nextval' in col_default) or is_identity == 'YES':
if data_type == 'integer':
col_def += "SERIAL"
is_serial = True
elif data_type == 'bigint':
col_def += "BIGSERIAL"
is_serial = True
if not is_serial:
# Veri tipini ekle
if max_length and data_type == 'character varying':
col_def += f"VARCHAR({max_length})"
elif max_length and data_type == 'character':
col_def += f"CHAR({max_length})"
else:
col_def += data_type.upper()
# NOT NULL
if is_nullable == 'NO':
col_def += " NOT NULL"
# DEFAULT değer
if col_default:
col_def += f" DEFAULT {col_default}"
col_defs.append(col_def)
f.write(",\n".join(col_defs))
f.write("\n);\n\n")
# Veriyi al ve INSERT komutları oluştur
# Kolon isimlerini al
cursor.execute(sql.SQL("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = %s
ORDER BY ordinal_position;
"""), [table_name])
columns = [row[0] for row in cursor.fetchall()]
if not columns:
continue
cursor.execute(sql.SQL("SELECT * FROM {}").format(sql.Identifier(table_name)))
rows = cursor.fetchall()
if rows:
f.write(f"-- Data for table: {table_name}\n")
# INSERT şablonu hazırla
cols_str = ', '.join([f'"{c}"' for c in columns]) # Identifier quoting
placeholders = ', '.join(['%s'] * len(columns))
insert_template = f"INSERT INTO \"{table_name}\" ({cols_str}) VALUES ({placeholders})"
for row in rows:
# mogrify kullanarak güvenli SQL oluştur
try:
# mogrify bytes döndürür, decode etmemiz lazım
safe_sql = cursor.mogrify(insert_template, row).decode('utf-8')
f.write(f"{safe_sql};\n")
except Exception as row_err:
print(f"Row error in {table_name}: {row_err}")
continue
f.write("\n")
# Sequence'leri sıfırla
f.write("\n-- Reset sequences\n")
cursor.execute("""
SELECT
c.relname as sequence_name,
t.relname as table_name,
a.attname as column_name
FROM pg_class c
JOIN pg_depend d ON d.objid = c.oid
JOIN pg_class t ON d.refobjid = t.oid
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
WHERE c.relkind = 'S'
AND t.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public');
""")
sequences = cursor.fetchall()
for seq_name, tbl_name, col_name in sequences:
f.write(f"SELECT setval('{seq_name}', (SELECT COALESCE(MAX(\"{col_name}\"), 1) FROM \"{tbl_name}\"));\n")
cursor.close()
conn.close()
# Başarılı
file_size = os.path.getsize(backup_path)
backup_obj.file_path = backup_path
backup_obj.file_size = file_size
backup_obj.status = 'completed'
backup_obj.completed_at = timezone.now()
backup_obj.save()
return True, f"Yedekleme başarıyla tamamlandı: {backup_filename}"
except Exception as e:
backup_obj.status = 'failed'
backup_obj.error_message = str(e)
backup_obj.save()
return False, f"Yedekleme hatası: {str(e)}"
def restore_backup(self, backup_path):
"""
TAMAMEN OTOMATIK FULL RESTORE
Manuel işlem gerektirmez!
"""
try:
if not os.path.exists(backup_path):
return False, "Yedek dosyası bulunamadı"
with open(backup_path, 'r', encoding='utf-8') as f:
sql_content = f.read()
# HOTFIX 1: 'order' gibi keywordlerin tırnak içine alınmaması sorununu düzelt
import re
sql_content = re.sub(r'(\s+)order(\s+[A-Z]+)', r'\1"order"\2', sql_content)
# HOTFIX 2: SERIAL/Sequence düzeltmesi
# "id INTEGER NOT NULL DEFAULT nextval(...)" -> "id SERIAL"
sql_content = re.sub(r'INTEGER\s+NOT\s+NULL\s+DEFAULT\s+nextval\(\'[^\']+\'(:?::regclass)?\)', 'SERIAL', sql_content)
sql_content = re.sub(r'BIGINT\s+NOT\s+NULL\s+DEFAULT\s+nextval\(\'[^\']+\'(:?::regclass)?\)', 'BIGSERIAL', sql_content)
# HOTFIX 3: "id" kolonları INTEGER/BIGINT NOT NULL ise (ve default yoksa) SERIAL yap
# Bu durum Identity kolonlarının yanlış yedeklenmesi sonucu oluşur
sql_content = re.sub(r'"id"\s+INTEGER\s+NOT\s+NULL(?!(\s+DEFAULT))', '"id" SERIAL', sql_content)
sql_content = re.sub(r'"id"\s+BIGINT\s+NOT\s+NULL(?!(\s+DEFAULT))', '"id" BIGSERIAL', sql_content)
# HOTFIX 4: setval satırlarını kaldır (çünkü biz kendimiz yeniden ayarlıyoruz ve isimler değişmiş olabilir)
# Lines starting with SELECT setval...
sql_lines = []
for line in sql_content.split('\n'):
if 'SELECT setval' in line and 'django_migrations' in line or 'SELECT setval' in line:
continue # Skip setvals from file
sql_lines.append(line)
sql_content = '\n'.join(sql_lines)
conn = self.get_connection()
conn.autocommit = True
cursor = conn.cursor()
try:
print("=" * 60)
print("TAM OTOMATIK RESTORE (YENI VERSIYON)")
print("=" * 60)
# 1. DROP tüm tablolar
print("\n1. Temizleniyor...")
cursor.execute("SELECT tablename FROM pg_tables WHERE schemaname = 'public';")
tables = cursor.fetchall()
for (t,) in tables:
print(f" Dropping {t}...")
cursor.execute(f'DROP TABLE IF EXISTS "{t}" CASCADE;')
print(" ✓ Temizlendi")
# 2. SQL Execution - Tek seferde çalıştır
print("\n2. SQL Dosyası Çalıştırılıyor...")
# execute() methodu çoklu sorguları çalıştırabilir (psycopg2 özelliği)
try:
cursor.execute(sql_content)
print(" ✓ SQL Script çalıştırıldı")
except Exception as sql_err:
print(f" SQL HATA: {sql_err}")
raise sql_err
print(" ✓ SQL Script çalıştırıldı")
# 3. Sequence'ler (SQL script içinde genelde vardır ama garanti olsun)
print("\n3. Sequence'ler Kontrol Ediliyor...")
cursor.execute("""
SELECT c.relname, t.relname, a.attname
FROM pg_class c
JOIN pg_depend d ON d.objid = c.oid
JOIN pg_class t ON d.refobjid = t.oid
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
WHERE c.relkind = 'S' AND t.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public');
""")
for seq, tbl, col in cursor.fetchall():
try:
cursor.execute(f"SELECT setval('{seq}', COALESCE((SELECT MAX({col}) FROM {tbl}), 1));")
except:
pass
print(" ✓ Ayarlandı")
cursor.close()
conn.close()
print("\n" + "=" * 60)
print("RESTORE TAMAMLANDI!")
print("=" * 60)
return True, "Restore başarıyla tamamlandı!"
except Exception as e:
print(f"\nHATA: {e}")
cursor.close()
conn.close()
raise
except Exception as e:
return False, f"Geri yükleme hatası: {str(e)}"
def delete_backup_file(self, backup_path):
"""Yedek dosyasını fiziksel olarak siler"""
try:
if os.path.exists(backup_path):
os.remove(backup_path)
return True, "Yedek dosyası silindi"
else:
return False, "Yedek dosyası bulunamadı"
except Exception as e:
return False, f"Dosya silme hatası: {str(e)}"

0
blog/__init__.py Normal file
View File

101
blog/admin.py Normal file
View File

@@ -0,0 +1,101 @@
from django.contrib import admin
from django.utils.safestring import mark_safe
from blog.models import Category, Tags, Post, Comment, CategoryView
# Register your models here.
class PostAdmin(admin.ModelAdmin):
list_display = ('title', 'post_resim', 'is_active', 'post_kategorileri', 'slug')
list_filter = ('is_active', 'categories')
search_fields = ('title', 'is_active', 'slug', 'content')
list_editable = ('is_active', 'slug',) # Removed 'price' as it is not a field
class Meta:
model = Post
def save_model(self, request, obj, form, change):
"""Admin'de kaydetme sırasında image'ı thumb'a da kopyala"""
# Model save metodu zaten bu işi yapıyor, buradaki koda gerek yok aslında
# ama form üzerinden gelen veriyi kontrol etmek için bırakılabilir.
# Ancak model save metodundaki mantık daha sağlam olduğu için burayı sadeleştiriyoruz.
super().save_model(request, obj, form, change)
def formatted_hit_count(self, obj):
return obj.current_hit_count if obj.current_hit_count > 0 else '-'
formatted_hit_count.admin_order_field = 'hit_count'
formatted_hit_count.short_description = 'Hits'
def post_tags(self, obj):
tags = '<ul>'
for tag in obj.tags.all():
tags += '<li>' + tag.tag + '</li>'
tags += '</ul>'
return mark_safe(tags)
def post_kategorileri(self, obj):
html = '<ul>'
for category in obj.categories.all():
html += '<li>' + category.title + '</li>'
html += '</ul>'
return mark_safe(html)
def post_resim(self, obj):
if obj.image:
# Uygulama adı 'blog' olduğu için URL yapısı /admin/blog/post/... olmalı
return mark_safe(
'<a href="/admin/blog/post/{}/change/"><img src="{}" width="50" height="50" style="object-fit: cover;" /></a>'.format(
obj.id, obj.image.url))
return mark_safe('Resim Yok')
post_resim.short_description = 'Kurs Resmi'
admin.site.register(Post, PostAdmin)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('title', 'parent_category', 'is_active', 'created_at', 'order') # Removed 'view_count' and 'unique_view_count'
list_filter = ('title', 'is_active', 'created_at', 'parent')
search_fields = ('title', 'is_active', 'slug')
list_editable = ('is_active', 'order')
class Meta:
model = Category
def parent_category(self, obj):
if obj.parent:
return obj.parent.title
return "Ana Kategori"
parent_category.short_description = 'Üst Kategori'
admin.site.register(Category, CategoryAdmin)
class TagsAdmin(admin.ModelAdmin):
list_display = ('tag', 'created_at',)
list_filter = ('tag',)
search_fields = ('tag',)
class Meta:
model = Tags
admin.site.register(Tags, TagsAdmin)
class CategoryViewAdmin(admin.ModelAdmin):
list_display = ('category', 'ip_address', 'created_at')
list_filter = ('created_at', 'category')
search_fields = ('ip_address', 'category__title')
readonly_fields = ('category', 'ip_address', 'user_agent', 'created_at')
class Meta:
model = CategoryView
admin.site.register(CategoryView, CategoryViewAdmin)
admin.site.register(Comment)

9
blog/apps.py Normal file
View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
name = 'blog'
default_auto_field = 'django.db.models.BigAutoField'
def ready(self):
import blog.signals

View File

View File

View File

@@ -0,0 +1,85 @@
import os
import random
import string
import requests
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.core.files.base import ContentFile
from blog.models import Post, Category, Tags
try:
from faker import Faker
fake = Faker()
HAS_FAKER = True
except ImportError:
HAS_FAKER = False
class Command(BaseCommand):
help = 'Creates 300 fake posts with random images'
def handle(self, *args, **kwargs):
User = get_user_model()
# Prefer an existing staff user, otherwise any existing user, otherwise create a fallback user
user = User.objects.filter(is_staff=True).first() or User.objects.first()
if not user:
self.stdout.write('Hiç kullanıcı bulunamadı, `fakeuser` oluşturuluyor...')
user = User.objects.create(username='fakeuser', email='fake@example.com')
user.set_unusable_password()
user.save()
categories = list(Category.objects.all())
if not categories:
self.stdout.write(self.style.ERROR('Lütfen önce en az bir kategori oluşturun!'))
return
tags = list(Tags.objects.all())
if not tags:
self.stdout.write('Tag bulunamadı, oluşturuluyor...')
for i in range(5):
tag_name = self.get_random_string(8) if not HAS_FAKER else fake.word()
Tags.objects.create(tag=tag_name)
tags = list(Tags.objects.all())
self.stdout.write('300 adet fake post oluşturuluyor...')
for i in range(300):
if HAS_FAKER:
title = fake.sentence(nb_words=6).replace('.', '')
content = '\n\n'.join(fake.paragraphs(nb=5))
keywords = ", ".join(fake.words(nb=5))
else:
title = self.get_random_string(30)
content = self.get_random_string(500)
keywords = self.get_random_string(20)
post = Post(
user=user,
title=title,
content=content,
keywords=keywords,
video='none',
is_active=True,
is_front=True
)
post.save()
# ManyToMany ilişkileri
post.categories.add(random.choice(categories))
post.tags.add(random.choice(tags))
# Resim ekle
try:
# Picsum'dan rastgele resim (800x600)
img_url = f"https://picsum.photos/seed/{random.randint(1, 10000)}/800/600"
response = requests.get(img_url, timeout=10)
if response.status_code == 200:
file_name = f"fake_post_{i}_{random.randint(1000,9999)}.jpg"
post.image.save(file_name, ContentFile(response.content), save=True)
self.stdout.write(f'Post {i+1}/300 oluşturuldu: {title} (Resimli)')
else:
self.stdout.write(f'Post {i+1}/300 oluşturuldu: {title} (Resimsiz - İndirme hatası)')
except Exception as e:
self.stdout.write(f'Post {i+1}/300 oluşturuldu: {title} (Resimsiz - Hata: {str(e)})')
def get_random_string(self, length):
letters = string.ascii_letters + string.digits + ' '
return ''.join(random.choice(letters) for i in range(length))

View File

@@ -0,0 +1,121 @@
import random
import requests
from django.core.management.base import BaseCommand
from django.core.files import File
from django.core.files.temp import NamedTemporaryFile
from django.contrib.auth import get_user_model
from faker import Faker
from blog.models import Category, Tags, Post
User = get_user_model()
class Command(BaseCommand):
help = 'Seeds the database with fake data for blog app'
def handle(self, *args, **kwargs):
self.stdout.write('Seeding data...')
fake = Faker()
# Ensure a user exists
user, created = User.objects.get_or_create(
email='admin@example.com',
defaults={'first_name': 'Admin', 'is_staff': True, 'is_superuser': True}
)
if created:
user.set_password('admin')
user.save()
self.stdout.write(self.style.SUCCESS(f'Created user: {user.email}'))
else:
self.stdout.write(self.style.SUCCESS(f'Using existing user: {user.email}'))
# Create Categories
categories_data = {
'Teknoloji': ['Yazılım', 'Donanım', 'Yapay Zeka'],
'Yaşam': ['Seyahat', 'Sağlık', 'Yemek'],
'Spor': ['Futbol', 'Basketbol', 'Voleybol'],
'Eğlence': ['Sinema', 'Müzik', 'Oyun'],
}
created_categories = []
for parent_name, children in categories_data.items():
parent, _ = Category.objects.get_or_create(
title=parent_name,
defaults={
'keywords': f'{parent_name}, blog, kategori',
'description': f'{parent_name} kategorisi açıklaması',
'is_active': True
}
)
created_categories.append(parent)
self.stdout.write(f'Created/Found Category: {parent.title}')
for child_name in children:
child, _ = Category.objects.get_or_create(
title=child_name,
parent=parent,
defaults={
'keywords': f'{child_name}, {parent_name}, blog',
'description': f'{child_name} alt kategorisi açıklaması',
'is_active': True
}
)
created_categories.append(child)
self.stdout.write(f' - Created/Found Subcategory: {child.title}')
# Create Tags
tags_list = ['Python', 'Django', 'Web Development', 'Coding', 'Tech', 'News', 'Tutorial', 'Tips', 'Health', 'Travel']
created_tags = []
for tag_name in tags_list:
tag, _ = Tags.objects.get_or_create(
tag=tag_name,
defaults={'is_active': True}
)
created_tags.append(tag)
self.stdout.write(f'Created/Found Tag: {tag.tag}')
# Create Posts
self.stdout.write('Creating posts...')
for i in range(30): # Create 30 posts
title = fake.sentence(nb_words=6).replace('.', '')
content = f"<p>{fake.paragraph(nb_sentences=10)}</p><p>{fake.paragraph(nb_sentences=5)}</p>"
post, created = Post.objects.get_or_create(
title=title,
defaults={
'user': user,
'content': content,
'keywords': ', '.join(fake.words(nb=5)),
'is_active': True,
'is_front': True,
'video': 'none'
}
)
if created:
# Add Categories
post_cats = random.sample(created_categories, k=random.randint(1, 3))
post.categories.set(post_cats)
# Add Tags
post_tags = random.sample(created_tags, k=random.randint(1, 4))
post.tags.set(post_tags)
# Fetch and save image
image_url = f"https://picsum.photos/800/600?random={i}"
try:
response = requests.get(image_url, timeout=10)
if response.status_code == 200:
img_temp = NamedTemporaryFile(delete=True)
img_temp.write(response.content)
img_temp.flush()
post.image.save(f"post_{i}.jpg", File(img_temp), save=True)
self.stdout.write(f' - Downloaded image for post: {title}')
except Exception as e:
self.stdout.write(self.style.WARNING(f' - Could not download image for post {title}: {e}'))
post.save()
self.stdout.write(self.style.SUCCESS(f'Created Post: {post.title}'))
else:
self.stdout.write(f'Post already exists: {post.title}')
self.stdout.write(self.style.SUCCESS('Data seeding completed successfully!'))

View File

@@ -0,0 +1,127 @@
# Generated by Django 6.0.2 on 2026-02-13 21:12
import autoslug.fields
import core.utils.utils
import django.db.models.deletion
import imagekit.models.fields
import tinymce.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Tags',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tag', models.CharField(max_length=254, verbose_name='Post Tagları')),
('slug', autoslug.fields.AutoSlugField(blank=True, editable=True, max_length=250, populate_from='tag', unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Güncelleme Tarihi')),
('is_active', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=True, verbose_name='Yayındamı')),
],
options={
'verbose_name': 'Post Tagı',
'verbose_name_plural': 'Post Tagları',
'db_table': 'tags',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=254, verbose_name='Kategori')),
('keywords', models.CharField(max_length=254, verbose_name='Seo Kelimeleri Aralarına Virgül Koyunuz')),
('description', models.CharField(max_length=254, verbose_name='ıklama')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Güncelleme Tarihi')),
('is_active', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=True, verbose_name='Yayındamı')),
('order', models.IntegerField(db_index=True, default=1, verbose_name='Görüntülenme Sırası')),
('slug', autoslug.fields.AutoSlugField(blank=True, editable=True, max_length=250, populate_from='title', unique=True)),
('image', imagekit.models.fields.ProcessedImageField(blank=True, null=True, upload_to=core.utils.utils.UniquePathAndRename('uploads/category'), verbose_name='Resim 630 x 653 Olmali ve Transparan PNG Olmali')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child', to='blog.category', verbose_name='Üst Kategorisi')),
],
options={
'verbose_name': 'Post Kategori',
'verbose_name_plural': 'Post Kategorilerileri',
'db_table': 'categories',
'ordering': ['order'],
'unique_together': {('slug', 'parent')},
},
),
migrations.CreateModel(
name='Post',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=254, verbose_name='Post Başlığı')),
('content', tinymce.models.HTMLField(blank=True, null=True, verbose_name='Post İçeriği')),
('keywords', models.CharField(max_length=254, verbose_name='Seo Kelimeleri Aralarına Virgül Koyunuz')),
('image', imagekit.models.fields.ProcessedImageField(blank=True, null=True, upload_to=core.utils.utils.UniquePathAndRename('uploads/post'))),
('thumb', imagekit.models.fields.ProcessedImageField(blank=True, editable=False, null=True, upload_to=core.utils.utils.UniquePathAndRename('uploads/post/thumb'))),
('video', models.CharField(blank=True, default='none', max_length=254, null=True, verbose_name='Video')),
('slug', autoslug.fields.AutoSlugField(blank=True, editable=True, max_length=250, populate_from='title', unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Güncelleme Tarihi')),
('is_active', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=True, verbose_name='Yayındamı ?')),
('is_front', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=True, verbose_name='Önde Görünsünmü ?')),
('categories', models.ManyToManyField(related_name='c_categories', to='blog.category', verbose_name='Post Kategorisi')),
('parent', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child', to='blog.post', verbose_name='Konular')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL)),
('tags', models.ManyToManyField(related_name='tags', to='blog.tags', verbose_name='Post Tagları')),
],
options={
'verbose_name': 'Post',
'verbose_name_plural': 'Posts',
'db_table': 'posts',
'ordering': ['created_at'],
'unique_together': {('slug',)},
},
),
migrations.CreateModel(
name='CategoryView',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip_address', models.GenericIPAddressField(verbose_name='IP Adresi')),
('user_agent', models.TextField(blank=True, null=True, verbose_name='User Agent')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Ziyaret Tarihi')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_views', to='blog.category')),
],
options={
'verbose_name': 'Kategori Ziyareti',
'verbose_name_plural': 'Kategori Ziyaretleri',
'db_table': 'category_views',
'indexes': [models.Index(fields=['category', 'ip_address', 'created_at'], name='category_vi_categor_234334_idx')],
},
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=254, verbose_name='Yorum Başlığı')),
('body', models.TextField(verbose_name='Yorum')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Güncelleme Tarihi')),
('is_active', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=True, verbose_name='Yayındamı')),
('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='title', unique=True)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child', to='blog.comment')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cuser', to=settings.AUTH_USER_MODEL)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_product', to='blog.post')),
],
options={
'verbose_name': 'Post Yorum',
'verbose_name_plural': 'Post Yorumları',
'db_table': 'comments',
'ordering': ['-created_at'],
'unique_together': {('slug', 'parent')},
},
),
]

View File

239
blog/models.py Normal file
View File

@@ -0,0 +1,239 @@
import os
from autoslug import AutoSlugField
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import models
from imagekit.models import ProcessedImageField
from tinymce.models import HTMLField
from core.utils.utils import image_optimizer
# Create your models here.
class Category(models.Model):
aktif = (
(True, 'Evet'),
(False, 'Hayır'),
)
title = models.CharField(max_length=254, verbose_name="Kategori")
keywords = models.CharField(max_length=254, verbose_name="Seo Kelimeleri Aralarına Virgül Koyunuz")
description = models.CharField(max_length=254, verbose_name="ıklama")
created_at = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Oluşturulma Tarihi")
updated_at = models.DateTimeField(auto_now=True, editable=False, verbose_name="Güncelleme Tarihi")
is_active = models.BooleanField(default=True, verbose_name='Yayındamı', choices=aktif)
order = models.IntegerField(verbose_name='Görüntülenme Sırası', default=1, db_index=True)
slug = AutoSlugField(populate_from='title', null=False, unique=True, editable=True, db_index=True, max_length=250,
blank=True)
parent = models.ForeignKey('self', related_name='child', on_delete=models.CASCADE, blank=True, null=True,
verbose_name='Üst Kategorisi')
image = ProcessedImageField(**image_optimizer('uploads/category', 300, 300, 85, 'PNG'),
verbose_name='Resim 630 x 653 Olmali ve Transparan PNG Olmali', blank=True, null=True)
class Meta:
ordering = ["order"]
db_table = 'categories'
verbose_name_plural = "Post Kategorilerileri"
verbose_name = "Post Kategori"
unique_together = ('slug', 'parent',)
def get_slug(self):
slug = self.title.replace('ı', "i").replace('İ', 'i')
number = 1
while Category.objects.filter(slug=slug).exists():
slug = '{}-{}'.format(slug, number)
number += 1
return slug
def save(self, *args, **kwargs):
if not self.slug:
self.slug = self.get_slug()
super().save(*args, **kwargs)
def __str__(self):
full_path = [self.title]
k = self.parent
while k is not None:
full_path.append(k.title)
k = k.parent
return ' -> '.join(full_path[::-1])
class Tags(models.Model):
aktif = (
(True, 'Evet'),
(False, 'Hayır'),
)
tag = models.CharField(max_length=254, verbose_name="Post Tagları")
slug = AutoSlugField(populate_from='tag', null=False, unique=True, editable=True, db_index=True, max_length=250,
blank=True)
created_at = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Oluşturulma Tarihi")
updated_at = models.DateTimeField(auto_now=True, editable=False, verbose_name="Güncelleme Tarihi")
is_active = models.BooleanField(default=True, verbose_name='Yayındamı', choices=aktif)
class Meta:
ordering = ["-created_at"]
db_table = 'tags'
verbose_name_plural = "Post Tagları"
verbose_name = "Post Tagı"
def get_slug(self):
slug = self.tag.replace('ı', "i").replace('İ', 'i')
number = 1
while Tags.objects.filter(slug=slug).exists():
slug = '{}-{}'.format(slug, number)
number += 1
return slug
def save(self, *args, **kwargs):
if not self.slug:
self.slug = self.get_slug()
super().save(*args, **kwargs)
def __str__(self):
return self.tag
class Post(models.Model):
aktif = (
(True, 'Evet'),
(False, 'Hayır'),
)
title = models.CharField(max_length=254, verbose_name="Post Başlığı")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='posts', null=True, blank=True)
content = HTMLField(blank=True, null=True, verbose_name='Post İçeriği')
categories = models.ManyToManyField(Category, verbose_name="Post Kategorisi", related_name='c_categories')
keywords = models.CharField(max_length=254, verbose_name="Seo Kelimeleri Aralarına Virgül Koyunuz")
tags = models.ManyToManyField(Tags, verbose_name="Post Tagları", related_name='tags')
image = ProcessedImageField(**image_optimizer('uploads/post', 1170, 580, 85, 'avif'), null=True, blank=True)
thumb = ProcessedImageField(**image_optimizer('uploads/post/thumb', 348, 160, 85, 'avif'), null=True, blank=True,editable=False)
video = models.CharField(verbose_name="Video", null=True, blank=True, max_length=254, default='none')
slug = AutoSlugField(populate_from='title', null=False, unique=True, editable=True, db_index=True, max_length=250,
blank=True)
created_at = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Oluşturulma Tarihi")
updated_at = models.DateTimeField(auto_now=True, editable=False, verbose_name="Güncelleme Tarihi")
is_active = models.BooleanField(default=True, verbose_name='Yayındamı ?', choices=aktif)
is_front = models.BooleanField(default=True, verbose_name='Önde Görünsünmü ?', choices=aktif)
parent = models.ForeignKey('self', related_name='child', on_delete=models.CASCADE, blank=True, null=True,
editable=False,
verbose_name='Konular')
class Meta:
ordering = ["created_at"]
db_table = 'posts'
verbose_name_plural = "Posts"
verbose_name = "Post"
unique_together = ('slug',)
def get_slug(self):
slug = self.title.replace('ı', "i").replace('İ', 'i')
number = 1
while Post.objects.filter(slug=slug).exists():
slug = '{}-{}'.format(slug, number)
number += 1
return slug
def save(self, *args, **kwargs):
if not self.slug:
self.slug = self.get_slug()
if self.image:
# Eğer yeni bir kayıt ise veya resim değişmişse
update_thumb = False
if not self.pk:
update_thumb = True
else:
try:
old_instance = self.__class__.objects.get(pk=self.pk)
if self.image != old_instance.image:
update_thumb = True
except self.__class__.DoesNotExist:
pass
if update_thumb:
try:
if hasattr(self.image, 'closed') and self.image.closed:
self.image.open()
if hasattr(self.image, 'seek'):
self.image.seek(0)
content = self.image.read()
filename = os.path.basename(self.image.name)
# Doğrudan alana ata, super().save() işleyecek
self.thumb = ContentFile(content, name=filename)
except Exception:
pass
finally:
if hasattr(self.image, 'seek'):
self.image.seek(0)
super().save(*args, **kwargs)
def __str__(self):
return f"Postlar: {self.title}"
class CategoryView(models.Model):
"""Kategori ziyaretlerini takip etmek için model"""
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='category_views')
ip_address = models.GenericIPAddressField(verbose_name='IP Adresi')
user_agent = models.TextField(blank=True, null=True, verbose_name='User Agent')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Ziyaret Tarihi')
class Meta:
db_table = 'category_views'
verbose_name = 'Kategori Ziyareti'
verbose_name_plural = 'Kategori Ziyaretleri'
# unique_together kısıtlamasını kaldırdık - artık günlük bazda kontrol edeceğiz
indexes = [
models.Index(fields=['category', 'ip_address', 'created_at']),
]
def __str__(self):
return f"{self.category.title} - {self.ip_address} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
class Comment(models.Model):
aktif = (
(True, 'Evet'),
(False, 'Hayır'),
)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='cuser')
product = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='_product')
title = models.CharField(max_length=254, verbose_name="Yorum Başlığı")
body = models.TextField(verbose_name='Yorum')
created_at = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Oluşturulma Tarihi")
updated_at = models.DateTimeField(auto_now=True, editable=False, verbose_name="Güncelleme Tarihi")
is_active = models.BooleanField(default=True, verbose_name='Yayındamı', choices=aktif)
slug = AutoSlugField(populate_from='title', null=False, unique=True, editable=False, db_index=True)
parent = models.ForeignKey('self', related_name='child', on_delete=models.CASCADE, blank=True, null=True)
class Meta:
ordering = ["-created_at"]
db_table = 'comments'
verbose_name_plural = "Post Yorumları"
verbose_name = "Post Yorum"
unique_together = ('slug', 'parent',)
def get_slug(self):
slug = self.title.replace('ı', "i").replace('İ', 'i')
number = 1
while Comment.objects.filter(slug=slug).exists():
slug = '{}-{}'.format(slug, number)
number += 1
return slug
def save(self, *args, **kwargs):
if not self.slug:
self.slug = self.get_slug()
super().save(*args, **kwargs)
def __str__(self):
full_path = [self.title]
k = self.parent
while k is not None:
full_path.append(k.title)
k = k.parent
return ' -> '.join(full_path[::-1])

89
blog/serializers.py Normal file
View File

@@ -0,0 +1,89 @@
from rest_framework import serializers
from blog.models import Category, Post, Tags
class CateSerializer(serializers.ModelSerializer):
parent = serializers.StringRelatedField() # ID yerine __str__ metodundaki değeri döndürür
class Meta:
model = Category
fields = ['title', 'parent', 'is_active', 'created_at', 'order', 'slug', 'image', 'keywords', 'description']
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tags
fields = ['tag', 'slug']
class PostSerializer(serializers.ModelSerializer):
categories = CateSerializer(read_only=True, many=True)
# Tags için sadece tag ismini döndürmek daha temiz olabilir, ama mevcut yapıyı koruyalım
# Eğer sadece isim listesi istenirse: tags = serializers.SlugRelatedField(many=True, read_only=True, slug_field='tag')
tags = TagSerializer(read_only=True, many=True)
class Meta:
model = Post
fields = ['title', 'content', 'categories', 'keywords', 'tags', 'image', 'thumb', 'video',
'slug', 'created_at', 'updated_at', 'is_active', 'is_front']
# fields = '__all__'
class PostSYalinerializer(serializers.ModelSerializer):
class Meta:
model = Post
fields = ['slug', ]
# fields = '__all__'
class CategorySerializer(serializers.ModelSerializer):
posts = PostSYalinerializer(source='c_categories', read_only=True, many=True)
child = serializers.SerializerMethodField()
class Meta:
model = Category
fields = ['title', 'parent', 'is_active', 'created_at', 'order', 'slug', 'image', 'keywords', 'description',
'posts', 'child']
def get_child(self, obj):
serializer = self.__class__(obj.child.all(), many=True, context=self.context)
return serializer.data
class CategoryPostSerializer(serializers.ModelSerializer):
posts = serializers.SerializerMethodField()
child = serializers.SerializerMethodField()
class Meta:
model = Category
fields = ['title', 'parent', 'is_active', 'created_at', 'order', 'slug', 'image', 'keywords', 'description',
'posts', 'child']
def get_posts(self, obj):
# Pagination context'ini al
paginator = self.context.get('paginator')
request = self.context.get('request')
posts = obj.c_categories.all()
if paginator and request:
# Pagination uygula
paginated_posts = paginator.paginate_queryset(posts, request)
serializer = PostSerializer(paginated_posts, many=True, context=self.context)
return {
'results': serializer.data,
'count': posts.count(),
'next': paginator.get_next_link(),
'previous': paginator.get_previous_link(),
}
else:
# Pagination yoksa normal döndür
serializer = PostSerializer(posts, many=True, context=self.context)
return serializer.data
def get_child(self, obj):
serializer = self.__class__(obj.child.all(), many=True, context=self.context)
return serializer.data

52
blog/signals.py Normal file
View File

@@ -0,0 +1,52 @@
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.core.files.base import ContentFile
from .models import Post
@receiver(pre_save, sender=Post)
def update_post_thumb(sender, instance, **kwargs):
"""
Post kaydedilmeden önce, image alanı doluysa thumb'ı da güncelle
"""
if instance.image:
# Yeni kayıt veya image güncellenmiş mi kontrol et
should_update_thumb = False
if instance.pk:
try:
old_instance = Post.objects.get(pk=instance.pk)
# Image değişmişse thumb'ı da güncelle
if str(old_instance.image) != str(instance.image):
should_update_thumb = True
except Post.DoesNotExist:
# Kayıt bulunamadı, yeni kayıt gibi davran
should_update_thumb = True
else:
# Yeni kayıt (pk yok)
should_update_thumb = True
if should_update_thumb and hasattr(instance.image, 'file'):
# Image dosyasını thumb alanına kopyala
try:
# Image dosyasının içeriğini oku
instance.image.file.seek(0)
image_content = instance.image.file.read()
instance.image.file.seek(0)
# Dosya adını al
image_name = instance.image.name.split('/')[-1]
# Thumb alanına kaydet
instance.thumb.save(
image_name,
ContentFile(image_content),
save=False
)
except Exception as e:
print(f"Thumb oluşturma hatası: {e}")
import traceback
traceback.print_exc()

40
blog/tasks.py Normal file
View File

@@ -0,0 +1,40 @@
from celery import shared_task
from django.core.mail import send_mail
from django.conf import settings
@shared_task
def send_comment_notification_email(comment_title, comment_body, post_title, user_email):
"""
Yeni bir yorum yapıldığında admin'e e-posta gönderir.
"""
subject = f'Yeni Yorum: {post_title}'
message = f"""
Merhaba Admin,
"{post_title}" başlıklı yazıya yeni bir yorum yapıldı.
Yorum Yapan: {user_email}
Başlık: {comment_title}
Yorum: {comment_body}
Kontrol etmek için admin paneline giriş yapabilirsiniz.
"""
# Admin e-posta adresini settings'den veya doğrudan buraya yazabilirsiniz
# Örnek olarak settings.DEFAULT_FROM_EMAIL kullanıldı, admin listesi de kullanılabilir
admin_email = settings.DEFAULT_FROM_EMAIL
# Eğer settings.ADMINS tanımlıysa oradaki ilk kişiye de atılabilir
if hasattr(settings, 'ADMINS') and settings.ADMINS:
recipient_list = [email for name, email in settings.ADMINS]
else:
# Fallback olarak bir email
recipient_list = ['admin@example.com']
send_mail(
subject,
message,
settings.DEFAULT_FROM_EMAIL,
recipient_list,
fail_silently=False,
)

3
blog/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
blog/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from blog.views import CategoryList, CategoryDetail, PostDetail, PostList
urlpatterns = [
path('categories/', CategoryList.as_view(), name='categories.list'),
path('categories/<slug:slug>/', CategoryDetail.as_view(), name='categories.details'),
path('post/', PostList.as_view(), name='post.list'),
path('post/<slug:slug>/', PostDetail.as_view(), name='post.details'),
]

47
blog/views.py Normal file
View File

@@ -0,0 +1,47 @@
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.pagination import PageNumberPagination
from blog.models import Post, Category
from blog.serializers import PostSerializer, CategorySerializer, CategoryPostSerializer
from core.utils.Permission import ReadOnly
class StandardResultsSetPagination(PageNumberPagination):
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100
# Create your views here.
class CategoryList(ListAPIView):
permission_classes = [ReadOnly]
queryset = Category.objects.order_by('order').filter(is_active=True, parent__isnull=True).all()
# serializer_class = ParentSerializer
serializer_class = CategorySerializer
class CategoryDetail(RetrieveAPIView):
permission_classes = [ReadOnly]
queryset = Category.objects.order_by('order').filter(is_active=True).all()
serializer_class = CategoryPostSerializer
lookup_field = 'slug' # Slug ile arama yapılacak
def get_serializer_context(self):
context = super().get_serializer_context()
context['paginator'] = StandardResultsSetPagination()
return context
# Create your views here.
class PostList(ListAPIView):
permission_classes = [ReadOnly]
queryset = Post.objects.all()
serializer_class = PostSerializer
pagination_class = StandardResultsSetPagination
class PostDetail(RetrieveAPIView):
permission_classes = [ReadOnly]
queryset = Post.objects.all()
serializer_class = PostSerializer
lookup_field = 'slug' # Slug ile arama yapılacak

43
build/Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# Python 3.14.2 base image kullan
FROM python:3.14.2-slim
# Çalışma ortamı değişkenlerini ayarla
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Çalışma dizinini oluştur
WORKDIR /app
# Sistem bağımlılıklarını yükle (PostgreSQL ve diğer gerekli paketler için)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
pkg-config \
default-libmysqlclient-dev \
postgresql-client \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Python bağımlılıklarını kopyala ve yükle
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Proje dosyalarını kopyala
COPY . .
# Static dosyaları topla
RUN python manage.py collectstatic --noinput --clear || true
# Media ve staticfiles dizinlerini oluştur
RUN mkdir -p /app/media /app/staticfiles
# Port 8000'i aç
EXPOSE 8000
# Entrypoint scriptini çalıştırılabilir yap
RUN chmod +x /app/entrypoint.sh || true
# Entrypoint ve varsayılan komut
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

75
build/requirements.txt Normal file
View File

@@ -0,0 +1,75 @@
amqp==5.3.1
asgiref==3.11.1
billiard==4.2.4
celery==5.6.2
celery-types==0.24.0
certifi==2026.1.4
cffi==2.0.0
charset-normalizer==3.4.4
click==8.3.1
click-didyoumean==0.3.1
click-plugins==1.1.1.2
click-repl==0.3.0
cryptography==46.0.5
defusedxml==0.7.1
Django==6.0.2
django-appconf==1.2.0
django-autoslug==1.9.9
django-celery-beat==2.1.0
django-ckeditor-5==0.2.19
django-cleanup==9.0.0
django-colorfield==0.14.0
django-cors-headers==4.9.0
django-cropper-image==1.0.5
django-environ==0.12.0
django-filter==25.2
django-imagekit==6.0.0
django-redis==6.0.0
django-stubs==5.2.9
django-stubs-ext==5.2.9
django-timezone-field==4.2.3
django-tinymce==5.0.0
django_celery_results==2.6.0
djangorestframework==3.16.1
djangorestframework-stubs==3.16.8
djangorestframework_simplejwt==5.5.1
djoser==2.3.3
Faker==40.4.0
flower==2.0.1
gunicorn==25.1.0
hiredis==3.3.0
humanize==4.15.0
idna==3.11
kombu==5.6.2
Markdown==3.10.2
mysqlclient==2.2.8
oauthlib==3.3.1
packaging==26.0
pilkit==3.0
pillow==12.1.1
prometheus_client==0.24.1
prompt_toolkit==3.0.52
psycopg2-binary==2.9.11
pycparser==3.0
PyJWT==2.11.0
python-crontab==3.3.0
python-dateutil==2.9.0.post0
python-dotenv==1.2.1
python3-openid==3.2.0
pytz==2025.2
redis==7.1.1
requests==2.32.5
requests-oauthlib==2.0.0
six==1.17.0
social-auth-app-django==5.7.0
social-auth-core==4.8.5
sqlparse==0.5.5
tornado==6.5.4
types-PyYAML==6.0.12.20250915
typing_extensions==4.15.0
tzdata==2025.3
tzlocal==5.3.1
urllib3==2.6.3
vine==5.1.0
wcwidth==0.6.0
whitenoise==6.11.0

28
caddy/Caddyfile Normal file
View File

@@ -0,0 +1,28 @@
:80 {
# Büyük upload limiti (Nginx'teki client_max_body_size 100M eşdeğeri)
request_body {
max_size 100MB
}
# Static dosyalar
handle_path /static/* {
root * /app/staticfiles
header Cache-Control "public, immutable"
file_server
}
# Media dosyalar
handle_path /media/* {
root * /app/media
header Cache-Control "public"
file_server
}
# Diğer tüm istekler Django'ya
reverse_proxy web_beyhan:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}

4
caddy/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM caddy:2
# Build context repo root'u (.) olduğu için caddy/Caddyfile yolu kullanıyoruz
COPY caddy/Caddyfile /etc/caddy/Caddyfile

5
core/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
# Bu dosya Django projesinin başlangıç noktasıdır.
# Celery'yi Django ile entegre etmek için gerekli
from .celery import app as celery_app
__all__ = ('celery_app',)

16
core/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for core project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_asgi_application()

22
core/celery.py Normal file
View File

@@ -0,0 +1,22 @@
import os
from celery import Celery
from django.conf import settings
# Django ayarlarını yükle
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
app = Celery('core')
# Django settings'ten Celery ayarlarını yükle
app.config_from_object('django.conf:settings', namespace='CELERY')
# Tüm Django app'lerinden task'ları otomatik keşfet
app.autodiscover_tasks()
# Timezone ayarını zorla
app.conf.enable_utc = settings.CELERY_ENABLE_UTC
app.conf.timezone = settings.CELERY_TIMEZONE
@app.task(bind=True)
def debug_task(self):
print(f'Request: {self.request!r}')

225
core/settings.py Normal file
View File

@@ -0,0 +1,225 @@
"""
Django settings for core project.
Generated by 'django-admin startproject' using Django 6.0.2.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
from pathlib import Path
import os
import environ
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env()
environ.Env.read_env(BASE_DIR / '.env')
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env('SECRET_KEY',
default='django-insecure-FxIJkTCbCfj9VRywq1beYkfHqsbIB9RLqH7TxqyQJhvtceB9m8sfv04j15oHw2q0')
# SECURITY WARNING: don't run with debug turned on in production!
# DEBUG = env.bool('DEBUG', default=True)
DEBUG = True
ALLOWED_HOSTS = env.list(
'DJANGO_ALLOWED_HOSTS',
default=['localhost', '127.0.0.1', 'web_beyhan', 'caddy_beyhan'],
)
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party apps
'rest_framework',
'rest_framework_simplejwt',
'drf_spectacular',
# 'drf_spectacular_sidecar',
'djoser',
'corsheaders',
'django_filters',
'django_ckeditor_5',
'colorfield',
'social_django',
'django_celery_beat',
'django_celery_results',
'imagekit',
'django_cleanup',
'timezone_field',
'autoslug',
'tinymce',
'accounts',
'settings',
'blog',
'backup',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware', # Added CorsMiddleware
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'core.urls'
SITE_ID = 1
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates']
,
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'social_django.context_processors.backends',
'social_django.context_processors.login_redirect',
],
},
},
]
WSGI_APPLICATION = 'core.wsgi.application'
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': env('MYSQL_DATABASE', default='dj_beyhan'),
'USER': env('MYSQL_USER', default='dj_beyhan'),
'PASSWORD': env('MYSQL_PASSWORD', default='gg7678290'),
'HOST': env('MYSQL_HOST', default='10.80.80.70'),
'PORT': env('MYSQL_PORT', default='3306'),
# opsiyonel: kalıcı bağlantı (saniye), None = kapalı
'CONN_MAX_AGE': 600,
'OPTIONS': {
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
'charset': 'utf8mb4',
},
'TEST': {
'CHARSET': 'utf8mb4',
'COLLATION': 'utf8mb4_general_ci',
},
}
}
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
# 'LOCATION': 'redis://default:1923btO**@ares-redis-xrot7z:6379',
'LOCATION': os.getenv('REDIS_URL', 'redis://default:gg7678290@10.80.80.70:6379'),
# 'LOCATION': os.getenv('REDIS_URL', 'redis://127.0.0.1:6379/1'),
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
},
'KEY_PREFIX': 'dj52',
'TIMEOUT': 300, # 5 dakika default timeout
}
}
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
# LANGUAGE_CODE = 'tr'
# TIME_ZONE = 'Europe/Istanbul'
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
BASE_DIR / 'static',
]
# Media files (User uploaded files)
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
AUTH_USER_MODEL = 'accounts.CustomUser'
# CKEditor 5 settings
CKEDITOR_5_CONFIGS = {
'default': {
'toolbar': ['heading', '|', 'bold', 'italic', 'link',
'bulletedList', 'numberedList', 'blockQuote', 'imageUpload', ],
}
}
# REST Framework settings
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
SPECTACULAR_SETTINGS = {
'TITLE': 'Your Project API',
'DESCRIPTION': 'Your project description',
'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': False,
# OTHER SETTINGS
}
# Celery settings
CELERY_BROKER_URL = env('CELERY_BROKER_URL', default='redis://localhost:6379/0')
CELERY_RESULT_BACKEND = 'django-db'
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = TIME_ZONE
CELERY_ENABLE_UTC = True # Celery'nin UTC kullanmasını zorla
# django-celery-beat 2.1.0 expects pytz.localize; disable tz-aware mode for compatibility with zoneinfo.
DJANGO_CELERY_BEAT_TZ_AWARE = False
# CORS settings
CORS_ALLOW_ALL_ORIGINS = True # For development only, configure properly for production

40
core/urls.py Normal file
View File

@@ -0,0 +1,40 @@
"""
URL configuration for core project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/6.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
urlpatterns = [
# YOUR PATTERNS
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
# Optional UI:
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
path('admin/', admin.site.urls),
path('api/v1/', include('accounts.urls')),
path('api/v1/', include('settings.urls')),
path('api/v1/', include('blog.urls')),
path('tinymce/', include('tinymce.urls')),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

9
core/utils/Permission.py Normal file
View File

@@ -0,0 +1,9 @@
from rest_framework.permissions import BasePermission, SAFE_METHODS
class ReadOnly(BasePermission):
"""
Yalnızca okuma işlemlerine izin verir.
"""
def has_permission(self, request, view):
# SAFE_METHODS: ('GET', 'HEAD', 'OPTIONS')
return request.method in SAFE_METHODS

0
core/utils/__init__.py Normal file
View File

40
core/utils/utils.py Normal file
View File

@@ -0,0 +1,40 @@
import os
import uuid
from django.utils.deconstruct import deconstructible
from imagekit.processors import ResizeToFill
class ConvertToRGBA(object):
"""Converts an image to RGBA mode."""
def process(self, img):
if img.mode not in ('RGBA', 'LA'):
img = img.convert('RGBA')
return img
@deconstructible
class UniquePathAndRename(object):
def __init__(self, upload_to):
self.upload_to = upload_to
def __call__(self, instance, filename):
ext = filename.split('.')[-1]
new_filename = f"{uuid.uuid4().hex}.{ext}"
return os.path.join(self.upload_to, new_filename)
def image_optimizer(upload_to, width, height, quality, img_format):
"""
ProcessedImageField için gerekli olan `upload_to`, `processors`, `format`
ve `options` parametrelerini dinamik olarak oluşturur.
"""
processors = [ResizeToFill(width, height)]
if img_format == 'PNG':
processors.insert(0, ConvertToRGBA())
return {
'upload_to': UniquePathAndRename(upload_to),
'processors': processors,
'format': img_format,
'options': {'quality': quality}
}

16
core/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for core project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()

0
db.sqlite3 Normal file
View File

132
docker-compose.beat.yml Normal file
View File

@@ -0,0 +1,132 @@
services:
web_beyhan:
build:
context: .
dockerfile: ./build/Dockerfile
container_name: web_beyhan
command: gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3
dns:
- 8.8.8.8
- 8.8.4.4
volumes:
- static_volume:/app/staticfiles
- media_volume:/app/media
expose:
- 8000
networks:
- dokploy-network
environment:
- DEBUG=0
- SECRET_KEY=${SECRET_KEY}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS}
# Celery ayarları
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db}
# Email Settings (Optional)
- EMAIL_BACKEND=${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend}
- EMAIL_HOST=${EMAIL_HOST:-10.80.80.70}
- EMAIL_PORT=${EMAIL_PORT:-1025}
- EMAIL_HOST_USER=${EMAIL_HOST_USER}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-False}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-True}
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-noreply@localhost}
restart: unless-stopped
celery_beyhan:
build:
context: .
dockerfile: ./build/Dockerfile
container_name: celery_beyhan
command: celery -A core worker -l info
dns:
- 8.8.8.8
- 8.8.4.4
volumes:
- media_volume:/app/media
networks:
- dokploy-network
environment:
- DEBUG=0
- SECRET_KEY=${SECRET_KEY}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS}
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db}
depends_on:
- web_beyhan
restart: unless-stopped
celery_beat_beyhan:
build:
context: .
dockerfile: ./build/Dockerfile
container_name: celery_beat_beyhan
command: celery -A core beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler --pidfile=
dns:
- 8.8.8.8
- 8.8.4.4
volumes:
- media_volume:/app/media
networks:
- dokploy-network
environment:
- DEBUG=0
- SECRET_KEY=${SECRET_KEY}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS}
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db}
# Email Settings (Optional)
- EMAIL_BACKEND=${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend}
- EMAIL_HOST=${EMAIL_HOST:-10.80.80.70}
- EMAIL_PORT=${EMAIL_PORT:-1025}
- EMAIL_HOST_USER=${EMAIL_HOST_USER}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-False}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-True}
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-noreply@localhost}
depends_on:
- web_beyhan
restart: unless-stopped
caddy_beyhan:
build:
context: .
dockerfile: ./caddy/Dockerfile
container_name: caddy_beyhan
ports:
- "${CADDY_HTTP_PORT:-8080}:80"
networks:
- dokploy-network
volumes:
- static_volume:/app/staticfiles:ro
- media_volume:/app/media:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- web_beyhan
restart: unless-stopped
volumes:
static_volume:
media_volume:
caddy_data:
caddy_config:
networks:
dokploy-network:
external: true

111
docker-compose.cool.yml Normal file
View File

@@ -0,0 +1,111 @@
services:
web_beyhan:
build:
context: .
dockerfile: ./build/Dockerfile
container_name: web_beyhan
command: gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3
dns:
- 8.8.8.8
- 8.8.4.4
volumes:
- static_volume:/app/staticfiles
- media_volume:/app/media
expose:
- 8000
environment:
- DEBUG=0
- SECRET_KEY=${SECRET_KEY}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS}
# Celery ayarları
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db}
# Email Settings (Optional)
- EMAIL_BACKEND=${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend}
- EMAIL_HOST=${EMAIL_HOST:-10.80.80.70}
- EMAIL_PORT=${EMAIL_PORT:-1025}
- EMAIL_HOST_USER=${EMAIL_HOST_USER}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-False}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-True}
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-noreply@localhost}
restart: unless-stopped
celery_beyhan:
build:
context: .
dockerfile: ./build/Dockerfile
container_name: celery_beyhan
command: celery -A core worker -l info
dns:
- 8.8.8.8
- 8.8.4.4
volumes:
- media_volume:/app/media
environment:
- DEBUG=0
- SECRET_KEY=${SECRET_KEY}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS}
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
depends_on:
- web_beyhan
restart: unless-stopped
celery_beat_beyhan:
build:
context: .
dockerfile: ./build/Dockerfile
container_name: celery_beat_beyhan
command: celery -A core beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler --pidfile=
dns:
- 8.8.8.8
- 8.8.4.4
volumes:
- media_volume:/app/media
environment:
- DEBUG=0
- SECRET_KEY=${SECRET_KEY}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS}
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db}
depends_on:
- web_beyhan
restart: unless-stopped
caddy_beyhan:
build:
context: .
dockerfile: ./caddy/Dockerfile
container_name: caddy_beyhan
#ports:
# - "${CADDY_HTTP_PORT:-8080}:80"
volumes:
- static_volume:/app/staticfiles:ro
- media_volume:/app/media:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- web_beyhan
restart: unless-stopped
volumes:
static_volume:
media_volume:
caddy_data:
caddy_config:

123
docker-compose.yml Normal file
View File

@@ -0,0 +1,123 @@
services:
web_beyhan:
build:
context: .
dockerfile: ./build/Dockerfile
container_name: web_beyhan
command: gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3
dns:
- 8.8.8.8
- 8.8.4.4
volumes:
- static_volume:/app/staticfiles
- media_volume:/app/media
expose:
- 8000
networks:
- dokploy-network
environment:
- DEBUG=0
- SECRET_KEY=${SECRET_KEY}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS}
# Celery ayarları
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db}
# Email Settings (Optional)
- EMAIL_BACKEND=${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend}
- EMAIL_HOST=${EMAIL_HOST:-10.80.80.70}
- EMAIL_PORT=${EMAIL_PORT:-1025}
- EMAIL_HOST_USER=${EMAIL_HOST_USER}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-False}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-True}
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-noreply@localhost}
restart: unless-stopped
celery_beyhan:
build:
context: .
dockerfile: ./build/Dockerfile
container_name: celery_beyhan
command: celery -A core worker -l info
dns:
- 8.8.8.8
- 8.8.4.4
volumes:
- media_volume:/app/media
networks:
- dokploy-network
environment:
- DEBUG=0
- SECRET_KEY=${SECRET_KEY}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS}
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
depends_on:
- web_beyhan
restart: unless-stopped
celery_beat_beyhan:
build:
context: .
dockerfile: ./build/Dockerfile
container_name: celery_beat_beyhan
command: celery -A core beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler --pidfile=
dns:
- 8.8.8.8
- 8.8.4.4
volumes:
- media_volume:/app/media
networks:
- dokploy-network
environment:
- DEBUG=0
- SECRET_KEY=${SECRET_KEY}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS}
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db}
depends_on:
- web_beyhan
restart: unless-stopped
caddy_beyhan:
build:
context: .
dockerfile: ./caddy/Dockerfile
container_name: caddy_beyhan
#ports:
# - "${CADDY_HTTP_PORT:-8080}:80"
networks:
- dokploy-network
volumes:
- static_volume:/app/staticfiles:ro
- media_volume:/app/media:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- web_beyhan
restart: unless-stopped
volumes:
static_volume:
media_volume:
caddy_data:
caddy_config:
networks:
dokploy-network:
external: true

31
entrypoint.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Hata durumunda scripti durdur
set -e
# MySQL bağlantısını kontrol et (mevcut sunucu için)
echo "Checking MySQL connection..."
# Not: Mevcut MySQL sunucunuz zaten çalışıyor olmalı (10.80.80.70:3306)
# Veritabanı migrasyonlarını uygula
echo "Applying database migrations..."
python manage.py migrate --noinput
# Superuser oluştur (eğer yoksa)
echo "Creating superuser if it doesn't exist..."
python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
if not User.objects.filter(email='admin@example.com').exists():
User.objects.create_superuser('admin@example.com', 'admin')
print('Superuser created: admin@example.com / admin')
else:
print('Superuser already exists')
" || true
# Static dosyaları topla
echo "Collecting static files..."
python manage.py collectstatic --noinput --clear
echo "Starting server..."
exec "$@"

22
manage.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Some files were not shown because too many files have changed in this diff Show More