init medical training project
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for config 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', 'config.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,77 @@
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import APIException, ValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STATUS_TO_CODE = {
|
||||
400: 'BAD_REQUEST',
|
||||
401: 'AUTH_UNAUTHORIZED',
|
||||
403: 'PERMISSION_DENIED',
|
||||
404: 'NOT_FOUND',
|
||||
405: 'METHOD_NOT_ALLOWED',
|
||||
429: 'SYS_RATE_LIMIT',
|
||||
500: 'SYS_INTERNAL',
|
||||
503: 'SYS_DEPENDENCY_DOWN',
|
||||
}
|
||||
|
||||
|
||||
def custom_exception_handler(exc, context):
|
||||
trace_id = uuid.uuid4().hex[:12]
|
||||
response = drf_exception_handler(exc, context)
|
||||
|
||||
if response is None:
|
||||
logger.exception('Unhandled server error trace_id=%s', trace_id)
|
||||
return Response(
|
||||
{'code': 'SYS_INTERNAL', 'message': '服务器内部错误', 'details': None, 'trace_id': trace_id},
|
||||
status=500,
|
||||
)
|
||||
|
||||
data = response.data
|
||||
status_code = response.status_code
|
||||
|
||||
# Already structured by AppError or our custom exceptions
|
||||
if isinstance(data, dict) and 'code' in data and 'message' in data:
|
||||
response.data = {**data, 'trace_id': trace_id}
|
||||
return response
|
||||
|
||||
# DRF ValidationError: {'field': ['error msg']}
|
||||
if isinstance(exc, ValidationError):
|
||||
response.data = {
|
||||
'code': 'VALIDATION_ERROR',
|
||||
'message': '请求参数不合法',
|
||||
'details': data,
|
||||
'trace_id': trace_id,
|
||||
}
|
||||
return response
|
||||
|
||||
# Standard DRF exceptions (AuthenticationFailed, PermissionDenied, Throttled, etc.)
|
||||
message = _extract_message(data)
|
||||
response.data = {
|
||||
'code': _STATUS_TO_CODE.get(status_code, 'SYS_INTERNAL'),
|
||||
'message': message,
|
||||
'details': None,
|
||||
'trace_id': trace_id,
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
def _extract_message(data):
|
||||
if isinstance(data, dict):
|
||||
return str(data.get('detail', data))
|
||||
if isinstance(data, list) and data:
|
||||
return str(data[0])
|
||||
return str(data)
|
||||
|
||||
|
||||
class AppError(APIException):
|
||||
"""统一业务异常。视图中 raise AppError('CODE', '消息', details, status_code) 即可。"""
|
||||
|
||||
def __init__(self, code, message, details=None, status_code=400):
|
||||
self.status_code = status_code
|
||||
super().__init__(detail=code)
|
||||
# 绕过 DRF _get_error_details,防止 None 被转成字符串 "None"
|
||||
self.detail = {'code': code, 'message': message, 'details': details}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""自定义日志 Handler:按日期分文件,兼容 Windows 文件锁。
|
||||
|
||||
替代 TimedRotatingFileHandler,避免 Windows 上因 rename 操作遇到文件锁
|
||||
(PermissionError: [WinError 32])导致日志丢失的问题。
|
||||
|
||||
文件命名规则:{prefix}-YYYY-MM-DD.log
|
||||
日期切换时自动打开新文件,无需 rename 旧文件。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DailyFileHandler(logging.Handler):
|
||||
"""按日期自动分文件的日志 Handler。
|
||||
|
||||
与 TimedRotatingFileHandler 的关键区别:
|
||||
- 文件直接以日期命名,日期切换时打开新文件,**不 rename 旧文件**
|
||||
- 多进程(dev server + 测试 / 管理命令)可同时写入,互不阻塞
|
||||
- 自动清理超过 backup_count 天的旧文件
|
||||
|
||||
dictConfig 用法::
|
||||
|
||||
'audit_file': {
|
||||
'class': 'config.logging_handlers.DailyFileHandler',
|
||||
'dir_path': '/path/to/logs',
|
||||
'prefix': 'audit',
|
||||
'backup_count': 30,
|
||||
'formatter': 'verbose',
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, dir_path, prefix='audit', backup_count=30, encoding='utf-8'):
|
||||
super().__init__()
|
||||
self.dir_path = Path(dir_path)
|
||||
self.dir_path.mkdir(parents=True, exist_ok=True)
|
||||
self.prefix = prefix
|
||||
self.backup_count = backup_count
|
||||
self.encoding = encoding
|
||||
self._current_date = None
|
||||
self._stream = None
|
||||
self._open_today()
|
||||
|
||||
def _today(self):
|
||||
return time.strftime('%Y-%m-%d')
|
||||
|
||||
def _open_today(self):
|
||||
"""打开当天的日志文件(追加模式)。"""
|
||||
if self._stream:
|
||||
try:
|
||||
self._stream.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._current_date = self._today()
|
||||
filepath = self.dir_path / f'{self.prefix}-{self._current_date}.log'
|
||||
self._stream = open(filepath, 'a', encoding=self.encoding)
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
today = self._today()
|
||||
if today != self._current_date:
|
||||
self._open_today()
|
||||
self._cleanup_old_files()
|
||||
msg = self.format(record)
|
||||
self._stream.write(msg + '\n')
|
||||
self._stream.flush()
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
def close(self):
|
||||
self.acquire()
|
||||
try:
|
||||
if self._stream:
|
||||
try:
|
||||
self._stream.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._stream = None
|
||||
finally:
|
||||
self.release()
|
||||
super().close()
|
||||
|
||||
def _cleanup_old_files(self):
|
||||
"""删除超过 backup_count 天的旧日志文件。"""
|
||||
if self.backup_count <= 0:
|
||||
return
|
||||
try:
|
||||
files = sorted(self.dir_path.glob(f'{self.prefix}-*.log'))
|
||||
for f in files[:-self.backup_count]:
|
||||
f.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,138 @@
|
||||
"""API 请求/响应日志中间件。
|
||||
|
||||
记录每次 API 调用的完整信息:方法、路径、请求头、查询参数、请求体、
|
||||
响应状态码、响应头、响应体。
|
||||
日志输出到 `api_access` logger → `logs/api-access-YYYY-MM-DD.log`。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
api_logger = logging.getLogger('api_access')
|
||||
|
||||
# 跳过日志的路径前缀(静态文件、admin 等)
|
||||
_SKIP_PREFIXES = ('/static/', '/admin/', '/api/schema/', '/api/docs/')
|
||||
|
||||
# 请求体/响应体最大记录长度(字符)
|
||||
_MAX_BODY_LEN = 2000
|
||||
|
||||
# 需要记录的请求头(Django 中请求头加 HTTP_ 前缀并大写)
|
||||
_LOG_REQUEST_HEADERS = (
|
||||
'HTTP_AUTHORIZATION',
|
||||
'HTTP_ACCEPT',
|
||||
'HTTP_USER_AGENT',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
)
|
||||
|
||||
|
||||
def _truncate(text, max_len=_MAX_BODY_LEN):
|
||||
if len(text) > max_len:
|
||||
return text[:max_len] + f'... (truncated, total {len(text)} chars)'
|
||||
return text
|
||||
|
||||
|
||||
def _safe_json_body(body_bytes, content_type=''):
|
||||
"""尝试将请求/响应体解析为 JSON,失败则返回原始文本摘要。"""
|
||||
if not body_bytes:
|
||||
return ''
|
||||
# multipart (文件上传) 不记录原始内容
|
||||
if 'multipart' in content_type:
|
||||
return f'<multipart form data, {len(body_bytes)} bytes>'
|
||||
try:
|
||||
text = body_bytes.decode('utf-8')
|
||||
data = json.loads(text)
|
||||
return _truncate(json.dumps(data, ensure_ascii=False))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return _truncate(body_bytes.decode('utf-8', errors='replace'))
|
||||
|
||||
|
||||
class APIAccessLogMiddleware:
|
||||
"""记录 /api/ 请求与响应的中间件。"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
@staticmethod
|
||||
def _collect_request_headers(request):
|
||||
"""提取关键请求头。"""
|
||||
headers = {}
|
||||
ct = request.content_type or request.META.get('CONTENT_TYPE', '')
|
||||
if ct:
|
||||
headers['Content-Type'] = ct
|
||||
for meta_key in _LOG_REQUEST_HEADERS:
|
||||
val = request.META.get(meta_key)
|
||||
if val:
|
||||
name = meta_key.replace('HTTP_', '').replace('_', '-').title()
|
||||
headers[name] = val
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _collect_response_headers(response):
|
||||
"""提取关键响应头。"""
|
||||
keys = ('Content-Type', 'Content-Length', 'Allow',
|
||||
'X-Request-Id', 'Retry-After', 'WWW-Authenticate')
|
||||
headers = {}
|
||||
for k in keys:
|
||||
v = response.get(k)
|
||||
if v:
|
||||
headers[k] = v
|
||||
return headers
|
||||
|
||||
def __call__(self, request):
|
||||
path = request.path
|
||||
|
||||
if not path.startswith('/api/') or any(path.startswith(p) for p in _SKIP_PREFIXES):
|
||||
return self.get_response(request)
|
||||
|
||||
start = time.time()
|
||||
|
||||
req_headers = self._collect_request_headers(request)
|
||||
|
||||
req_content_type = request.content_type or ''
|
||||
if 'multipart' in req_content_type:
|
||||
form_fields = dict(request.POST)
|
||||
file_info = {
|
||||
name: f'<file: {f.name}, {f.size} bytes>'
|
||||
for name, f in request.FILES.items()
|
||||
}
|
||||
all_fields = {**form_fields, **file_info}
|
||||
req_body = _truncate(json.dumps(all_fields, ensure_ascii=False)) if all_fields else ''
|
||||
else:
|
||||
req_body = _safe_json_body(request.body, req_content_type)
|
||||
|
||||
query = dict(request.GET) if request.GET else ''
|
||||
|
||||
user_id = None
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
||||
user_id = request.user.id
|
||||
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
|
||||
resp_headers = self._collect_response_headers(response)
|
||||
|
||||
resp_content_type = response.get('Content-Type', '')
|
||||
resp_body = ''
|
||||
if hasattr(response, 'content'):
|
||||
resp_body = _safe_json_body(response.content, resp_content_type)
|
||||
|
||||
req_h_str = json.dumps(req_headers, ensure_ascii=False) if req_headers else ''
|
||||
resp_h_str = json.dumps(resp_headers, ensure_ascii=False) if resp_headers else ''
|
||||
|
||||
api_logger.info(
|
||||
'%s %s | user=%s | query=%s | status=%s | %dms\n'
|
||||
' >>> headers: %s\n'
|
||||
' >>> body: %s\n'
|
||||
' <<< headers: %s\n'
|
||||
' <<< body: %s',
|
||||
request.method, path,
|
||||
user_id, query, response.status_code, duration_ms,
|
||||
req_h_str, req_body,
|
||||
resp_h_str, resp_body,
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,252 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(BASE_DIR / '.env')
|
||||
|
||||
SECRET_KEY = 'django-insecure-!-mtect5n-yyxkp2m=j(8dz_yi$b3w3ddo&w#i(@4kv-spdthy'
|
||||
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
|
||||
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',
|
||||
'django_filters',
|
||||
'drf_spectacular',
|
||||
|
||||
# Local apps
|
||||
'apps.common',
|
||||
'apps.user',
|
||||
'apps.case',
|
||||
'apps.training',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'config.middleware.APIAccessLogMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
|
||||
# Database - MySQL
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.mysql',
|
||||
'NAME': os.getenv('DB_NAME', 'medical_training'),
|
||||
'USER': os.getenv('DB_USER', 'root'),
|
||||
'PASSWORD': os.getenv('DB_PASSWORD', ''),
|
||||
'HOST': os.getenv('DB_HOST', 'localhost'),
|
||||
'PORT': os.getenv('DB_PORT', '3306'),
|
||||
'OPTIONS': {
|
||||
'charset': 'utf8mb4',
|
||||
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
|
||||
},
|
||||
'TEST': {
|
||||
'NAME': 'test_medical_training',
|
||||
'CHARSET': 'utf8mb4',
|
||||
'COLLATION': 'utf8mb4_unicode_ci',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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'},
|
||||
]
|
||||
|
||||
|
||||
LANGUAGE_CODE = 'zh-hans'
|
||||
TIME_ZONE = 'Asia/Shanghai'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# ─── REST Framework ───────────────────────────────────────────────────────────
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'EXCEPTION_HANDLER': 'config.exceptions.custom_exception_handler',
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'apps.user.authentication.RedisBlacklistJWTAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 20,
|
||||
'DEFAULT_THROTTLE_CLASSES': [],
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
'sms_phone_minute': '1/minute',
|
||||
'sms_phone_day': '10/day',
|
||||
'sms_ip': '30/hour',
|
||||
'register_ip': '10/hour',
|
||||
'reset_phone': '5/hour',
|
||||
'pdf_parse_user': '20/hour',
|
||||
'scoring_rule_generate_user': '20/hour',
|
||||
},
|
||||
}
|
||||
|
||||
# ─── JWT ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': False,
|
||||
'ALGORITHM': 'HS256',
|
||||
'SIGNING_KEY': SECRET_KEY,
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
|
||||
}
|
||||
|
||||
# ─── Redis Cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django_redis.cache.RedisCache',
|
||||
'LOCATION': os.getenv('REDIS_URL', 'redis://127.0.0.1:6379/1'),
|
||||
'OPTIONS': {
|
||||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# ─── SMS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
SMS_CODE_EXPIRE = 300 # 验证码有效期(秒)
|
||||
SMS_CODE_INTERVAL = 60 # 发送间隔(秒)
|
||||
SMS_PROVIDER = os.getenv('SMS_PROVIDER', 'mock')
|
||||
|
||||
ALIYUN_SMS_ACCESS_KEY_ID = os.getenv('ALIYUN_SMS_ACCESS_KEY_ID', '')
|
||||
ALIYUN_SMS_ACCESS_KEY_SECRET = os.getenv('ALIYUN_SMS_ACCESS_KEY_SECRET', '')
|
||||
ALIYUN_SMS_SIGN_NAME = os.getenv('ALIYUN_SMS_SIGN_NAME', '医疗训练平台')
|
||||
ALIYUN_SMS_TEMPLATE_REGISTER = os.getenv('ALIYUN_SMS_TEMPLATE_REGISTER', '')
|
||||
ALIYUN_SMS_TEMPLATE_LOGIN = os.getenv('ALIYUN_SMS_TEMPLATE_LOGIN', '')
|
||||
ALIYUN_SMS_TEMPLATE_RESET = os.getenv('ALIYUN_SMS_TEMPLATE_RESET', '')
|
||||
|
||||
# ─── DeepSeek ─────────────────────────────────────────────────────────────────
|
||||
|
||||
DEEPSEEK_API_KEY = os.getenv('DEEPSEEK_API_KEY', '')
|
||||
DEEPSEEK_BASE_URL = os.getenv('DEEPSEEK_BASE_URL', 'https://api.deepseek.com')
|
||||
DEEPSEEK_MODEL = os.getenv('DEEPSEEK_MODEL', 'deepseek-chat')
|
||||
DEEPSEEK_TIMEOUT_SECONDS = int(os.getenv('DEEPSEEK_TIMEOUT_SECONDS', '120'))
|
||||
DEEPSEEK_MAX_RETRIES = int(os.getenv('DEEPSEEK_MAX_RETRIES', '1'))
|
||||
|
||||
# ─── Spectacular (Swagger / OpenAPI) ─────────────────────────────────────────
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': 'Medical Training API',
|
||||
'DESCRIPTION': '医疗训练系统 API 文档',
|
||||
'VERSION': '1.0.0',
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
'COMPONENT_SPLIT_REQUEST': True,
|
||||
# 修复同名枚举冲突(User.STATUS_CHOICES 与 CaseBase.STATUS_CHOICES 值相同,共用一个名称)
|
||||
'ENUM_NAME_OVERRIDES': {
|
||||
'CaseTypeEnum': 'apps.case.models.CaseBase.CASE_TYPE_CHOICES',
|
||||
'CreatableCaseTypeEnum': [('traditional', 'traditional'), ('teaching', 'teaching')],
|
||||
'CommonStatusEnum': 'apps.case.models.CaseBase.STATUS_CHOICES',
|
||||
'PublishStatusEnum': 'apps.case.models.CaseBase.PUBLISH_STATUS_CHOICES',
|
||||
'TrainingStatusEnum': 'apps.training.models.TrainingRecord.STATUS_CHOICES',
|
||||
'TeacherStudentStatusEnum': 'apps.user.models.TeacherStudentRelation.STATUS_CHOICES',
|
||||
},
|
||||
}
|
||||
|
||||
# ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
AUTH_USER_MODEL = 'user.User'
|
||||
|
||||
# ─── Logging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
LOGS_DIR = BASE_DIR / 'logs'
|
||||
LOGS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '{asctime} {levelname} [{name}] {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
'audit_file': {
|
||||
'class': 'config.logging_handlers.DailyFileHandler',
|
||||
'dir_path': str(LOGS_DIR),
|
||||
'prefix': 'audit',
|
||||
'backup_count': 30,
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
'api_access_file': {
|
||||
'class': 'config.logging_handlers.DailyFileHandler',
|
||||
'dir_path': str(LOGS_DIR),
|
||||
'prefix': 'api-access',
|
||||
'backup_count': 30,
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'audit': {
|
||||
'handlers': ['audit_file', 'console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'api_access': {
|
||||
'handlers': ['api_access_file'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
URL configuration for config 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.contrib import admin
|
||||
from django.urls import path, include
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
|
||||
# API Routes
|
||||
path('api/user/', include('apps.user.urls')),
|
||||
path('api/case/', include('apps.case.urls')),
|
||||
path('api/training/', include('apps.training.urls')),
|
||||
|
||||
# JWT Token
|
||||
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
|
||||
# API Documentation
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path('api/docs/swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||
path('api/docs/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for config 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', 'config.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
Reference in New Issue
Block a user