initial commit

This commit is contained in:
2026-03-11 17:12:28 +08:00
commit 5d1e1cb7cb
61 changed files with 2971 additions and 0 deletions

3
config/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .services.celery import app as celery_app
__all__ = ["celery_app"]

16
config/asgi.py Normal file
View File

@@ -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/5.2/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()

View File

@@ -0,0 +1 @@
"""Service configuration modules."""

View File

@@ -0,0 +1 @@
AUDITLOG_INCLUDE_ALL_MODELS = True

View File

@@ -0,0 +1,9 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery("qlockify")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

244
config/services/logging.py Normal file
View File

@@ -0,0 +1,244 @@
import logging
import logging.config
import time
from logging.handlers import RotatingFileHandler
from pathlib import Path
from pythonjsonlogger import jsonlogger
BASE_DIR = Path(__file__).resolve().parents[2]
LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(exist_ok=True)
# Custom formatter for console output (ERROR logs)
class CustomConsoleFormatter(logging.Formatter):
def format(self, record):
if isinstance(record.msg, dict):
msg_dict = record.msg
return (
f"{record.levelname} {self.formatTime(record, self.datefmt)} "
f"{msg_dict.get('request_method', '')} "
f"{msg_dict.get('request_url', '')} "
f"{msg_dict.get('status_code', '')} "
f"{msg_dict.get('remote_addr', '')} "
f"{msg_dict.get('duration_ms', 0)}ms "
f"{msg_dict.get('message', record.getMessage())}"
)
return super().format(record)
# Custom formatter for INFO console output (simpler format)
class CustomConsoleInfoFormatter(logging.Formatter):
def format(self, record):
if isinstance(record.msg, dict):
msg_dict = record.msg
return (
f"{record.levelname} {self.formatTime(record, self.datefmt)} "
f"{msg_dict.get('request_method', '')} "
f"{msg_dict.get('request_url', '')} "
f"{msg_dict.get('status_code', '')} "
f"{msg_dict.get('remote_addr', '')} "
f"{msg_dict.get('duration_ms', 0)}ms"
)
return super().format(record)
LOGGING = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {name} {module} {funcName} {lineno} {message}",
"style": "{",
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(levelname)s %(asctime)s %(name)s %(module)s %(funcName)s %(lineno)d %(message)s %(pathname)s",
},
"request_formatter": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(levelname)s %(asctime)s %(name)s %(message)s %(pathname)s %(lineno)d %(request_method)s %(request_url)s %(status_code)d %(remote_addr)s %(user_agent)s %(duration_ms)d", # noqa: E501
},
# Custom formatters for console
"error_console": {
"()": CustomConsoleFormatter,
"format": "%(levelname)s %(asctime)s %(name)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"info_console": {
"()": CustomConsoleInfoFormatter,
"format": "%(levelname)s %(asctime)s %(name)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
"console": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "verbose",
},
"error_console": {
"level": "ERROR",
"class": "logging.StreamHandler",
"formatter": "error_console",
},
"info_console": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "info_console",
},
"file": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "django.log"),
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "json",
},
"error_file": {
"level": "ERROR",
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "errors.log"),
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 5,
"formatter": "json",
},
"debug_file": {
"level": "DEBUG",
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "debug.log"),
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 5,
"formatter": "verbose",
},
"request_info_file": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "info.log"),
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "request_formatter",
},
"request_error_file": {
"level": "ERROR",
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "errors.log"),
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "request_formatter",
},
},
"loggers": {
"django.request": {
"handlers": ["error_console", "error_file"],
"level": "ERROR",
"propagate": False,
},
"django.server": {
"handlers": ["error_console"],
"level": "ERROR",
"propagate": False,
},
"myapp": {
"handlers": ["console", "file", "debug_file", "error_file"],
"level": "DEBUG",
"propagate": False,
},
"project.requests.info": {
"handlers": [
"request_info_file",
"info_console",
], # Use info_console for INFO
"level": "INFO",
"propagate": False,
},
"project.requests.error": {
"handlers": [
"request_error_file",
"error_console",
], # Use error_console for ERROR
"level": "ERROR",
"propagate": False,
},
"common.exceptions": {
"handlers": ["error_console", "error_file", "debug_file"],
"level": "ERROR",
"propagate": False,
},
"common.middleware": {
"handlers": ["error_console", "error_file", "debug_file"],
"level": "ERROR",
"propagate": False,
},
},
}
class RequestLoggingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.info_logger = logging.getLogger("project.requests.info")
self.error_logger = logging.getLogger("project.requests.error")
def __call__(self, request):
start = time.perf_counter()
try:
response = self.get_response(request)
except Exception:
duration_ms = int((time.perf_counter() - start) * 1000)
message = f"HTTP {request.method} request to {request.get_full_path()}"
log_data = {
"request_method": request.method,
"request_url": request.get_full_path(),
"status_code": 500,
"remote_addr": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
"duration_ms": duration_ms,
"message": message,
}
self.error_logger.exception(log_data)
raise
duration_ms = int((time.perf_counter() - start) * 1000)
message = f"HTTP {request.method} request to {request.get_full_path()}"
log_data = {
"request_method": request.method,
"request_url": request.get_full_path(),
"status_code": response.status_code,
"remote_addr": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
"duration_ms": duration_ms,
"message": message,
}
if response.status_code == 404:
log_data["message"] = f"Not Found: {request.get_full_path()}"
elif response.status_code == 401:
log_data["message"] = f"Unauthorized: {request.get_full_path()}"
if response.status_code >= 400:
self.error_logger.error(log_data)
else:
self.info_logger.info(log_data)
return response
def get_custom_logger(name):
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
custom_handler = RotatingFileHandler(
filename=str(LOG_DIR / f"{name}.log"),
maxBytes=1024 * 1024 * 15, # 15MB
backupCount=10,
)
custom_handler.setFormatter(jsonlogger.JsonFormatter())
if not logger.handlers:
logger.addHandler(custom_handler)
return logger
logging.config.dictConfig(LOGGING)

View File

@@ -0,0 +1,15 @@
import os
from urllib.parse import urljoin
from django.conf import settings
from django.core.files.storage import FileSystemStorage
class UploadStorage(FileSystemStorage):
"""
Storage for uploaded files (images, etc.)
Saves files under MEDIA_ROOT/uploads/
"""
location = os.path.join(settings.MEDIA_ROOT, "uploads")
base_url = urljoin(settings.MEDIA_URL, "uploads/")

46
config/services/unfold.py Normal file
View File

@@ -0,0 +1,46 @@
from django.conf import settings
UNFOLD = {
"SITE_TITLE": "Qlockify Admin",
"SITE_HEADER": "Qlockify Admin Panel",
"SITE_BRANDING": "Qlockify",
"SITE_URL": "/api/docs/",
"SITE_SYMBOL": "speed",
"SHOW_HISTORY": True,
"SHOW_VIEW_ON_SITE": True,
"ENVIRONMENT": "config.services.unfold.environment_callback",
"COLORS": {
"primary": {
"50": "#f5f7ff",
"100": "#e8edff",
"200": "#c7d2ff",
"300": "#a3b4ff",
"400": "#7a8dff",
"500": "#4f6bff",
"600": "#3f55d6",
"700": "#3243ab",
"800": "#263281",
"900": "#1b245b",
},
"gray": {
"50": "#f8fafc",
"100": "#f1f5f9",
"200": "#e2e8f0",
"300": "#cbd5e1",
"400": "#94a3b8",
"500": "#64748b",
"600": "#475569",
"700": "#334155",
"800": "#1f2937",
"900": "#0f172a",
},
},
"SIDEBAR": {
"show_search": True,
"show_all_applications": True,
},
}
def environment_callback(request):
return ["Development", "warning"] if settings.DEBUG else ["Production", "success"]

View File

@@ -0,0 +1,17 @@
import contextlib
import os
from .base import *
ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
if ENVIRONMENT == "production":
from .production import *
else:
from .development import *
from config.services.auditlog import *
from config.services.logging import *
from config.services.unfold import *
with contextlib.suppress(ImportError):
from .local import *

215
config/settings/base.py Normal file
View File

@@ -0,0 +1,215 @@
import os
from datetime import timedelta
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes")
DJANGO_APPS = [
"unfold",
"unfold.contrib.filters",
"unfold.contrib.forms",
"unfold.contrib.import_export",
"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",
"rest_framework.authtoken",
"rest_framework_simplejwt.token_blacklist",
"drf_spectacular",
"drf_spectacular_sidecar",
"django_filters",
"import_export",
"corsheaders",
"auditlog",
]
LOCAL_APPS = [
"apps.users",
"apps.workspaces",
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"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",
"core.middlewares.current_user.CurrentUserMiddleware",
"core.middlewares.exception_logging.ExceptionLoggingMiddleware",
"config.services.logging.RequestLoggingMiddleware",
"auditlog.middleware.AuditlogMiddleware",
]
ROOT_URLCONF = "config.urls"
AUTH_USER_MODEL = "users.User"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
ASGI_APPLICATION = "config.asgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("POSTGRES_DB"),
"USER": os.getenv("POSTGRES_USER"),
"PASSWORD": os.getenv("POSTGRES_PASSWORD"),
"HOST": os.getenv("POSTGRES_HOST"),
"PORT": os.getenv("POSTGRES_PORT"),
"CONN_MAX_AGE": 0,
"DISABLE_SERVER_SIDE_CURSORS": True,
}
}
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",
},
]
REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.AllowAny"],
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
"DATETIME_FORMAT": "%Y-%m-%d %H:%M",
"DATE_FORMAT": "%Y-%m-%d",
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler",
}
LANGUAGE_CODE = os.getenv("LANGUAGE_CODE", "en-us")
TIME_ZONE = os.getenv("TIME_ZONE", "Asia/Tehran")
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "static"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
SPECTACULAR_SETTINGS = {
"TITLE": "Qlockify.ir API Documentation",
"DESCRIPTION": "API documentation for Qlockify.ir",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": True,
"SWAGGER_UI_SETTINGS": {
"deepLinking": True,
},
"COMPONENT_SPLIT_REQUEST": True,
"ENUM_NAME_OVERRIDES": {},
"TAG_SORTING": "alpha",
"SWAGGER_UI_DIST": "SIDECAR",
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
"REDOC_DIST": "SIDECAR",
}
JWT_ACCESS_TOKEN_LIFETIME = int(os.getenv("JWT_ACCESS_TOKEN_LIFETIME_MINUTES", 60))
JWT_REFRESH_TOKEN_LIFETIME = int(os.getenv("JWT_REFRESH_TOKEN_LIFETIME_DAYS", 7))
JWT_ROTATE_REFRESH_TOKENS = os.getenv("JWT_ROTATE_REFRESH_TOKENS", "True") == "True"
JWT_BLACKLIST_AFTER_ROTATION = os.getenv("JWT_BLACKLIST_AFTER_ROTATION", "True") == "True"
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=JWT_ACCESS_TOKEN_LIFETIME),
"REFRESH_TOKEN_LIFETIME": timedelta(days=JWT_REFRESH_TOKEN_LIFETIME),
"ROTATE_REFRESH_TOKENS": JWT_ROTATE_REFRESH_TOKENS,
"BLACKLIST_AFTER_ROTATION": JWT_BLACKLIST_AFTER_ROTATION,
"ALGORITHM": JWT_ALGORITHM,
"SIGNING_KEY": SECRET_KEY,
"AUTH_HEADER_TYPES": ("Bearer",),
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "user_id",
}
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = os.getenv("REDIS_PORT", "6379")
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
if REDIS_PASSWORD:
REDIS_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/0"
else:
REDIS_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}/0"
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/1")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/1")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TASK_ALWAYS_EAGER = False
CELERY_IMPORTS = ("apps.users.tasks",)
CELERY_TIMEZONE = os.getenv("TIME_ZONE")
CELERY_TASK_TRACK_STARTED = True
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"images": {"BACKEND": "config.services.storage.UploadStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
SMS_APIKEY = os.getenv("SMS_APIKEY", "")
BASE_URL = os.getenv("BASE_URL", "")

View File

@@ -0,0 +1,23 @@
import os
from django.conf import settings
DEBUG = True
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
CSRF_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SAMESITE = "Lax"
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
CSRF_TRUSTED_ORIGINS = os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1").split(",")
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOWED_ORIGINS = os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(",")
# Django Debug Toolbar
INSTALLED_APPS = settings.INSTALLED_APPS + ["debug_toolbar"]
MIDDLEWARE = settings.MIDDLEWARE + ["debug_toolbar.middleware.DebugToolbarMiddleware"]
INTERNAL_IPS = ["127.0.0.1"]

8
config/settings/local.py Normal file
View File

@@ -0,0 +1,8 @@
"""
Local developer overrides.
Copy this file and set only the settings you want to override locally.
"""
# Example:
# DEBUG = True

View File

@@ -0,0 +1,16 @@
import os
DEBUG = False
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "None"
SESSION_COOKIE_SAMESITE = "None"
ALLOWED_HOSTS = [x for x in os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",") if x]
CSRF_TRUSTED_ORIGINS = [x for x in os.getenv("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if x]
CORS_ALLOW_ALL_ORIGINS = False
CORS_ALLOWED_ORIGINS = [x for x in os.getenv("DJANGO_CORS_ALLOWED_ORIGINS", "").split(",") if x]

27
config/urls.py Normal file
View File

@@ -0,0 +1,27 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
urlpatterns = [
path("admin/", admin.site.urls),
# API Documentations
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
# Apps
path("api/users/", include("apps.users.api.urls"), name="users"),
path('api/', include('apps.workspaces.api.urls')),
]
if settings.DEBUG:
import debug_toolbar
urlpatterns += [path("__debug__/", include(debug_toolbar.urls))]
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

16
config/wsgi.py Normal file
View File

@@ -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/5.1/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()