Browse Source

feat: add authentication serializers, views, account admin modules, and local configuration settings

main
sina_sajjadi 3 weeks ago
parent
commit
9c75a6c588
  1. 1
      apps/account/admin/__init__.py
  2. 27
      apps/account/admin/login_history.py
  3. 18
      apps/account/admin/notification.py
  4. 17
      apps/account/serializers/auth_serializers.py
  5. 19
      apps/account/urls_auth.py
  6. 1
      apps/account/views/auth_views.py
  7. 313
      config/settings/base.py
  8. 2
      config/settings/local.py
  9. 2
      manage.py
  10. 23
      utils/admin.py

1
apps/account/admin/__init__.py

@ -3,6 +3,7 @@ from django.template.loader import render_to_string
from .user import *
from .location import *
from .login_history import *
from .notification import *
from .verification import *

27
apps/account/admin/login_history.py

@ -0,0 +1,27 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin
from unfold.decorators import display
from unfold.contrib.filters.admin import RangeDateTimeFilter, AutocompleteSelectFilter
from apps.account.models.user import LoginHistory
from utils.admin import project_admin_site
@admin.register(LoginHistory, site=project_admin_site)
class LoginHistoryAdmin(ModelAdmin):
list_display = ('user', 'ip', 'country', 'city', 'device_os', 'display_at_time')
list_filter = [
('user', AutocompleteSelectFilter),
'device_os',
'country',
'city',
('at_time', RangeDateTimeFilter),
]
search_fields = ('user__email', 'user__fullname', 'ip', 'country', 'city', 'user_agent')
readonly_fields = ('at_time',)
ordering = ('-at_time',)
@display(description=_("Date & Time"))
def display_at_time(self, instance: LoginHistory):
return instance.at_time.strftime("%Y-%m-%d %H:%M") if instance.at_time else "-"

18
apps/account/admin/notification.py

@ -3,13 +3,23 @@ from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin
from utils.admin import project_admin_site
from apps.account.models import User, Notification
from apps.account.models import User, Notification, NotificationTemplate
@admin.register(Notification, site=project_admin_site)
class NotificationAdmin(ModelAdmin):
list_display = ('title', 'user', 'is_read', 'created_at')
list_filter = ('is_read', 'created_at')
search_fields = ('title', 'message', 'user__fullname')
list_display = ('title', 'user', 'notification_type', 'is_read', 'created_at')
list_filter = ('is_read', 'notification_type', 'created_at')
search_fields = ('title', 'message', 'user__fullname', 'user__email')
list_editable = ('is_read',)
ordering = ('-created_at',)
autocomplete_fields = ['user',]
@admin.register(NotificationTemplate, site=project_admin_site)
class NotificationTemplateAdmin(ModelAdmin):
list_display = ('name', 'notification_type', 'title', 'is_active')
list_filter = ('is_active',)
search_fields = ('name', 'notification_type', 'title', 'body')
list_editable = ('is_active',)
ordering = ('name',)

17
apps/account/serializers/auth_serializers.py

@ -153,6 +153,23 @@ class UserMeUpdateSerializer(serializers.ModelSerializer):
'birthdate',
]
def to_internal_value(self, data):
if hasattr(data, 'copy'):
mutable_data = data.copy()
else:
mutable_data = dict(data)
# Handle avatar: if avatar is a string (e.g. existing URL or empty string), do not treat as file
if 'avatar' in mutable_data:
avatar_val = mutable_data['avatar']
if isinstance(avatar_val, str):
if not avatar_val.strip() or avatar_val.startswith('http') or avatar_val.startswith('/'):
mutable_data.pop('avatar')
elif avatar_val is None:
mutable_data['avatar'] = None
return super().to_internal_value(mutable_data)
def update(self, instance, validated_data):
for attr, value in validated_data.items():
setattr(instance, attr, value)

19
apps/account/urls_auth.py

@ -1,4 +1,4 @@
from django.urls import path
from django.urls import path, re_path
from rest_framework_simplejwt.views import (
TokenRefreshView,
TokenVerifyView,
@ -14,18 +14,19 @@ from apps.account.views.auth_views import (
urlpatterns = [
# SimpleJWT Token Lifecycle
path('token/', CustomTokenObtainPairView.as_view(), name='auth_token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='auth_token_refresh'),
path('token/verify/', TokenVerifyView.as_view(), name='auth_token_verify'),
re_path(r'^token/?$', CustomTokenObtainPairView.as_view(), name='auth_token_obtain_pair'),
re_path(r'^token/refresh/?$', TokenRefreshView.as_view(), name='auth_token_refresh'),
re_path(r'^token/verify/?$', TokenVerifyView.as_view(), name='auth_token_verify'),
# Registration & Profile
path('register/', RegisterView.as_view(), name='auth_register'),
path('me/', UserMeView.as_view(), name='auth_me'),
re_path(r'^register/?$', RegisterView.as_view(), name='auth_register'),
re_path(r'^me/?$', UserMeView.as_view(), name='auth_me'),
# Verification Documents
path('documents/', VerificationDocumentListView.as_view(), name='auth_documents'),
re_path(r'^documents/?$', VerificationDocumentListView.as_view(), name='auth_documents'),
# Password Recovery
path('recover-password/', PasswordRecoverView.as_view(), name='auth_recover_password'),
path('reset-password/', PasswordResetView.as_view(), name='auth_reset_password'),
re_path(r'^recover-password/?$', PasswordRecoverView.as_view(), name='auth_recover_password'),
re_path(r'^reset-password/?$', PasswordResetView.as_view(), name='auth_reset_password'),
]

1
apps/account/views/auth_views.py

@ -72,6 +72,7 @@ class RegisterView(GenericAPIView):
class UserMeView(GenericAPIView):
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser, JSONParser]
serializer_class = UserMeSerializer
queryset = User.objects.all()

313
config/settings/base.py

@ -9,6 +9,7 @@ from django.urls import reverse_lazy
import environ
from django.utils.translation import gettext_lazy as _
import sentry_sdk
from corsheaders.defaults import default_headers
# Build paths inside the project: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@ -23,7 +24,7 @@ SECRET_KEY = env('DJANGO_SECRET_KEY', default='django-insecure-template-secret-k
DEBUG = env.bool('DJANGO_DEBUG', default=True)
ALLOWED_HOSTS = [host.strip() for host in env('DJANGO_ALLOWED_HOSTS', default='127.0.0.1,localhost,*').split(',') if host.strip()]
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in env('DJANGO_TRUSTED_ORIGINS', default='http://localhost:8000,http://127.0.0.1:8000').split(',') if origin.strip()]
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in env('DJANGO_TRUSTED_ORIGINS', default='http://localhost:8000,http://127.0.0.1:8000,http://localhost:8080,http://127.0.0.1:8080,http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://127.0.0.1:3000').split(',') if origin.strip()]
X_FRAME_OPTIONS = 'SAMEORIGIN'
@ -86,11 +87,11 @@ AUTHENTICATION_BACKENDS = [
AUTH_USER_MODEL = "account.User"
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.middleware.gzip.GZipMiddleware',
"whitenoise.middleware.WhiteNoiseMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@ -270,12 +271,20 @@ CENTRIFUGO_API_KEY = env('CENTRIFUGO_API_KEY', default='centrifugo_internal_api_
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOWED_ORIGINS = [
'http://localhost:8080',
'http://127.0.0.1:8080',
'http://localhost:5173',
'http://127.0.0.1:5173',
'http://localhost:3000',
'http://127.0.0.1:3000',
'https://shiadeepconnect.org',
]
CORS_ALLOW_HEADERS = list(default_headers) + [
'accept-language',
'content-disposition',
'x-csrftoken',
'x-requested-with',
]
# Internationalization
LANGUAGE_CODE = 'en'
@ -315,6 +324,14 @@ LOGIN_URL = "admin:login"
LOGIN_REDIRECT_URL = "/admin/"
def admin_url_generator(request, url_name):
from django.urls import reverse
try:
return reverse(f"admin:{url_name}")
except Exception:
return "#"
# Unfold Admin Interface Settings
UNFOLD = {
"SITE_TITLE": _("Admin Portal"),
@ -365,24 +382,290 @@ UNFOLD = {
],
},
{
"title": _("User Management"),
"title": _("User & Access Management"),
"separator": True,
"items": [
{
"title": _("All Users"),
"icon": "people",
"icon": "person",
"link": lambda request: admin_url_generator(request, "account_user_changelist"),
},
{
"title": _("Groups & Permissions"),
"icon": "shield",
"title": _("Groups & Roles"),
"icon": "admin_panel_settings",
"link": lambda request: admin_url_generator(request, "auth_group_changelist"),
"permission": lambda request: request.user.is_staff,
},
{
"title": _("Verification Documents"),
"icon": "verified",
"link": lambda request: admin_url_generator(request, "account_verificationdocument_changelist"),
},
{
"title": _("Login History"),
"icon": "history",
"link": lambda request: admin_url_generator(request, "account_loginhistory_changelist"),
},
{
"title": _("Location History"),
"icon": "location_on",
"link": lambda request: admin_url_generator(request, "account_locationhistory_changelist"),
},
{
"title": _("User Notifications"),
"icon": "notifications",
"link": lambda request: admin_url_generator(request, "account_notification_changelist"),
},
{
"title": _("Notification Templates"),
"icon": "mark_email_read",
"link": lambda request: admin_url_generator(request, "account_notificationtemplate_changelist"),
},
],
},
{
"title": _("Institutions & Profiles"),
"separator": True,
"items": [
{
"title": _("Institutions"),
"icon": "account_balance",
"link": lambda request: admin_url_generator(request, "profiles_institution_changelist"),
},
{
"title": _("Institution Members"),
"icon": "badge",
"link": lambda request: admin_url_generator(request, "profiles_institutionmember_changelist"),
},
{
"title": _("Historical Timelines"),
"icon": "timeline",
"link": lambda request: admin_url_generator(request, "profiles_institutiontimeline_changelist"),
},
{
"title": _("Media Gallery"),
"icon": "perm_media",
"link": lambda request: admin_url_generator(request, "profiles_institutionmedia_changelist"),
},
{
"title": _("Followers"),
"icon": "group_add",
"link": lambda request: admin_url_generator(request, "profiles_institutionfollower_changelist"),
},
],
},
{
"title": _("Content Management (CMS)"),
"separator": True,
"items": [
{
"title": _("Posts & Articles"),
"icon": "article",
"link": lambda request: admin_url_generator(request, "cms_post_changelist"),
},
{
"title": _("Categories"),
"icon": "category",
"link": lambda request: admin_url_generator(request, "cms_postcategory_changelist"),
},
{
"title": _("Tags"),
"icon": "label",
"link": lambda request: admin_url_generator(request, "cms_posttag_changelist"),
},
{
"title": _("Comments"),
"icon": "comment",
"link": lambda request: admin_url_generator(request, "cms_postcomment_changelist"),
},
{
"title": _("Likes"),
"icon": "thumb_up",
"link": lambda request: admin_url_generator(request, "cms_postlike_changelist"),
},
{
"title": _("Media Assets"),
"icon": "photo_library",
"link": lambda request: admin_url_generator(request, "cms_mediaasset_changelist"),
},
],
},
{
"title": _("Projects & Kanban"),
"separator": True,
"items": [
{
"title": _("Projects"),
"icon": "assignment",
"link": lambda request: admin_url_generator(request, "projects_project_changelist"),
},
{
"title": _("Kanban Tasks"),
"icon": "task_alt",
"link": lambda request: admin_url_generator(request, "projects_kanbantask_changelist"),
},
{
"title": _("Kanban Columns"),
"icon": "view_column",
"link": lambda request: admin_url_generator(request, "projects_kanbancolumn_changelist"),
},
{
"title": _("Project Documents"),
"icon": "description",
"link": lambda request: admin_url_generator(request, "projects_projectdocument_changelist"),
},
],
},
{
"title": _("Events & Calendar"),
"separator": True,
"items": [
{
"title": _("Events"),
"icon": "event",
"link": lambda request: admin_url_generator(request, "events_event_changelist"),
},
{
"title": _("Event Registrations"),
"icon": "how_to_reg",
"link": lambda request: admin_url_generator(request, "events_eventregistration_changelist"),
},
],
},
{
"title": _("Meetings & Scheduling"),
"separator": True,
"items": [
{
"title": _("Meeting Requests"),
"icon": "video_camera_front",
"link": lambda request: admin_url_generator(request, "meetings_meetingrequest_changelist"),
},
],
},
{
"title": _("Learning Management (LMS)"),
"separator": True,
"items": [
{
"title": _("Courses"),
"icon": "school",
"link": lambda request: admin_url_generator(request, "lms_course_changelist"),
},
{
"title": _("Course Modules"),
"icon": "view_module",
"link": lambda request: admin_url_generator(request, "lms_coursemodule_changelist"),
},
{
"title": _("Lessons"),
"icon": "menu_book",
"link": lambda request: admin_url_generator(request, "lms_lesson_changelist"),
},
{
"title": _("Quiz Questions"),
"icon": "quiz",
"link": lambda request: admin_url_generator(request, "lms_quizquestion_changelist"),
},
{
"title": _("Student Enrollments"),
"icon": "co_present",
"link": lambda request: admin_url_generator(request, "lms_courseenrollment_changelist"),
},
{
"title": _("Lesson Progress"),
"icon": "trending_up",
"link": lambda request: admin_url_generator(request, "lms_lessonprogress_changelist"),
},
{
"title": _("Digital Certificates"),
"icon": "card_membership",
"link": lambda request: admin_url_generator(request, "lms_digitalcertificate_changelist"),
},
],
},
{
"title": _("System Settings"),
"title": _("Ticketing & Support"),
"separator": True,
"items": [
{
"title": _("Support Tickets"),
"icon": "confirmation_number",
"link": lambda request: admin_url_generator(request, "tickets_ticket_changelist"),
},
{
"title": _("Ticket Messages"),
"icon": "forum",
"link": lambda request: admin_url_generator(request, "tickets_ticketmessage_changelist"),
},
{
"title": _("Support Inquiries"),
"icon": "contact_support",
"link": lambda request: admin_url_generator(request, "api_supportmessage_changelist"),
},
],
},
{
"title": _("Dynamic Forms & Surveys"),
"separator": True,
"items": [
{
"title": _("Form Definitions"),
"icon": "dynamic_form",
"link": lambda request: admin_url_generator(request, "dynamic_forms_formdefinition_changelist"),
},
{
"title": _("Form Responses"),
"icon": "checklist",
"link": lambda request: admin_url_generator(request, "dynamic_forms_formresponse_changelist"),
},
],
},
{
"title": _("Donations & Financials"),
"separator": True,
"items": [
{
"title": _("Donation Campaigns"),
"icon": "volunteer_activism",
"link": lambda request: admin_url_generator(request, "donations_donationcampaign_changelist"),
},
{
"title": _("Transactions"),
"icon": "payments",
"link": lambda request: admin_url_generator(request, "donations_donationtransaction_changelist"),
},
],
},
{
"title": _("Diplomacy & Field Reports"),
"separator": True,
"items": [
{
"title": _("Representative Reports"),
"icon": "public",
"link": lambda request: admin_url_generator(request, "diplomacy_representativereport_changelist"),
},
],
},
{
"title": _("Real-Time Communication"),
"separator": True,
"items": [
{
"title": _("Chat Rooms"),
"icon": "chat",
"link": lambda request: admin_url_generator(request, "chat_chatroom_changelist"),
},
{
"title": _("Chat Messages"),
"icon": "message",
"link": lambda request: admin_url_generator(request, "chat_chatmessage_changelist"),
},
],
},
{
"title": _("System & Automation"),
"separator": True,
"items": [
{
@ -396,9 +679,19 @@ UNFOLD = {
"link": lambda request: admin_url_generator(request, "api_appversion_changelist"),
},
{
"title": _("Support Inquiries"),
"icon": "mail",
"link": lambda request: admin_url_generator(request, "api_supportmessage_changelist"),
"title": _("Celery Periodic Tasks"),
"icon": "schedule",
"link": lambda request: admin_url_generator(request, "django_celery_beat_periodictask_changelist"),
},
{
"title": _("Crontab Schedules"),
"icon": "alarm",
"link": lambda request: admin_url_generator(request, "django_celery_beat_crontabschedule_changelist"),
},
{
"title": _("Interval Schedules"),
"icon": "timer",
"link": lambda request: admin_url_generator(request, "django_celery_beat_intervalschedule_changelist"),
},
],
},

2
config/settings/local.py

@ -19,12 +19,14 @@ DATABASES = {
# Redis & Cache (local Redis service on port 6379)
# --------------------------------------------------------------------------- #
REDIS_URL = 'redis://127.0.0.1:6379/0'
DJANGO_REDIS_IGNORE_EXCEPTIONS = True
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': REDIS_URL,
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'IGNORE_EXCEPTIONS': True,
}
}
}

2
manage.py

@ -13,7 +13,7 @@ if sys.platform == 'win32':
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.base')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.local')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:

23
utils/admin.py

@ -146,6 +146,17 @@ class LazyAdminSite(UnfoldAdminSite):
self._instance = None
self.name = name
def _sync_default_registry(self):
if self._instance is None:
return
try:
from django.contrib.admin import site as default_admin_site
for model, model_admin in default_admin_site._registry.items():
if model not in self._instance._registry:
self._instance._registry[model] = model_admin
except Exception:
pass
def _ensure_instance(self):
if self._instance is None:
self._instance = self._site_class(name=self._name)
@ -153,6 +164,7 @@ class LazyAdminSite(UnfoldAdminSite):
for attr in essential_attrs:
if hasattr(self._instance, attr):
setattr(self, attr, getattr(self._instance, attr))
self._sync_default_registry()
def _get_instance(self):
self._ensure_instance()
@ -167,9 +179,11 @@ class LazyAdminSite(UnfoldAdminSite):
@property
def urls(self):
self._ensure_instance()
return self._get_instance().urls
def get_urls(self):
self._ensure_instance()
return self._get_instance().get_urls()
def each_context(self, request):
@ -177,7 +191,16 @@ class LazyAdminSite(UnfoldAdminSite):
def register(self, model_or_iterable, admin_class=None, **options):
self._ensure_instance()
try:
return self._instance.register(model_or_iterable, admin_class, **options)
except Exception:
if admin_class is not None:
# If a custom admin_class was passed, update the registry
if isinstance(model_or_iterable, (list, tuple)):
for model in model_or_iterable:
self._instance._registry[model] = admin_class(model, self._instance)
else:
self._instance._registry[model_or_iterable] = admin_class(model_or_iterable, self._instance)
# Singleton instances

Loading…
Cancel
Save