feat(core): add core module with mixins, services, and background jobs

- Add Model Mixins: TimestampMixin, SoftDeleteMixin, AuditMixin, etc.
- Add Base Services: BaseService, BulkOperationsMixin, QueryOptimizerMixin
- Add Base ViewSets with bulk operations
- Add BackgroundJob model for Celery task tracking
- Add BaseAppCommand for management commands
- Add permissions, pagination, filters, cache, logging
- Migrate tests to factory_boy + faker
- Add CHANGELOG.md
- 297 tests passing
This commit is contained in:
2026-01-21 11:47:26 +01:00
parent 06b30fca02
commit f121445313
72 changed files with 9258 additions and 594 deletions

20
src/config/api_v1_urls.py Normal file
View File

@@ -0,0 +1,20 @@
"""
API v1 URL configuration.
All API endpoints are versioned under /api/v1/
"""
from apps.core.views import BackgroundJobListView, BackgroundJobStatusView
from django.urls import include, path
app_name = "api_v1"
jobs_urlpatterns = [
path("", BackgroundJobListView.as_view(), name="job-list"),
path("<str:task_id>/", BackgroundJobStatusView.as_view(), name="job-status"),
]
urlpatterns = [
path("users/", include("apps.user.urls")),
path("jobs/", include((jobs_urlpatterns, "jobs"))),
]

View File

@@ -1,27 +0,0 @@
import sys
from django.test.runner import DiscoverRunner
class CustomTestRunner(DiscoverRunner):
"""Custom test runner that avoids ipdb import issues"""
def __init__(self, *args, **kwargs):
# Отключаем использование ipdb
import os
os.environ["PYTHONBREAKPOINT"] = "pdb.set_trace"
super().__init__(*args, **kwargs)
def run_tests(self, test_labels, extra_tests=None, **kwargs):
# Проверяем, что ipdb не будет импортирован
# Создаем mock-модуль вместо None
mock_ipdb = type("MockModule", (), {"__getattr__": lambda s, n: None})()
sys.modules["ipdb"] = mock_ipdb
try:
return super().run_tests(test_labels, extra_tests, **kwargs)
finally:
# Восстанавливаем модуль если был
if "ipdb" in sys.modules:
del sys.modules["ipdb"]

View File

@@ -11,6 +11,9 @@ from decouple import Config, RepositoryEnv
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# Application version
APP_VERSION = "1.0.0"
# Load environment variables
ENV_FILE = BASE_DIR / ".env"
if ENV_FILE.exists():
@@ -50,15 +53,18 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
# Third-party apps
"rest_framework",
"django_filters",
"corsheaders",
"django_celery_beat",
"django_celery_results",
"drf_yasg",
# Local apps
"apps.core",
"apps.user",
]
MIDDLEWARE = [
"apps.core.middleware.RequestIDMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
@@ -104,6 +110,17 @@ DATABASES = {
},
}
# Cache configuration
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": get_env("REDIS_URL", "redis://localhost:6379/0"),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
},
}
# Password validation
AUTH_PASSWORD_VALIDATORS = [
@@ -152,12 +169,27 @@ REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
"DEFAULT_PAGINATION_CLASS": "apps.core.pagination.StandardPagination",
"PAGE_SIZE": 20,
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
],
"EXCEPTION_HANDLER": "apps.core.exception_handler.custom_exception_handler",
# Rate limiting
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "100/hour",
"user": "1000/hour",
},
}
# JWT settings
@@ -237,6 +269,3 @@ LOGGING = {
},
},
}
# Test runner configuration
TEST_RUNNER = "config.custom_test_runner.CustomTestRunner"

105
src/config/settings/test.py Normal file
View File

@@ -0,0 +1,105 @@
from .base import *
# Test settings
SECRET_KEY = "django-insecure-test-key-only-for-testing" # noqa: S105
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", "testserver"] # noqa: S104
# Use in-memory SQLite database for faster tests
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
"TEST": {
"NAME": ":memory:",
},
}
}
# Disable migrations for faster tests
class DisableMigrations:
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
MIGRATION_MODULES = DisableMigrations()
# Cache configuration for tests (use local memory)
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
}
}
# Email backend for tests
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
# Celery Configuration for Tests (use eager execution)
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
CELERY_BROKER_URL = "memory://"
CELERY_RESULT_BACKEND = "cache+memory://"
# Password hashers - use fast hasher for tests
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
# Disable logging during tests
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"null": {
"class": "logging.NullHandler",
},
},
"root": {
"handlers": ["null"],
},
"loggers": {
"django": {
"handlers": ["null"],
"propagate": False,
},
"django.request": {
"handlers": ["null"],
"propagate": False,
},
},
}
# Media files for tests
MEDIA_ROOT = "/tmp/test_media" # noqa: S108
# Static files for tests
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
# Disable CSRF for API tests and disable throttling
REST_FRAMEWORK = {
**globals().get("REST_FRAMEWORK", {}),
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"TEST_REQUEST_DEFAULT_FORMAT": "json",
# Disable throttling for tests
"DEFAULT_THROTTLE_CLASSES": [],
"DEFAULT_THROTTLE_RATES": {},
}
# JWT settings for tests
from datetime import timedelta
SIMPLE_JWT = {
**globals().get("SIMPLE_JWT", {}),
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
"ROTATE_REFRESH_TOKENS": True,
}

View File

@@ -27,16 +27,15 @@ schema_view = get_schema_view(
)
urlpatterns = [
path("admin/", admin.site.urls),
path("api/users/", include("apps.user.urls")),
path("api-auth/", include("rest_framework.urls")),
# Swagger documentation
path(
"swagger/",
"",
schema_view.with_ui("swagger", cache_timeout=0),
name="schema-swagger-ui",
),
path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
path("admin/", admin.site.urls),
path("health/", include("apps.core.urls")),
path("api/v1/", include("config.api_v1_urls", namespace="api_v1")),
path("auth/", include("rest_framework.urls")),
]
# Serve media files in development