diff --git a/apps/account/admin/__init__.py b/apps/account/admin/__init__.py index 3f0773c..bbb9a9e 100644 --- a/apps/account/admin/__init__.py +++ b/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 * diff --git a/apps/account/admin/login_history.py b/apps/account/admin/login_history.py new file mode 100644 index 0000000..0c57e2d --- /dev/null +++ b/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 "-" diff --git a/apps/account/admin/notification.py b/apps/account/admin/notification.py index 31c8ea5..cab1fc6 100644 --- a/apps/account/admin/notification.py +++ b/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',] \ No newline at end of file + 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',) \ No newline at end of file diff --git a/apps/account/serializers/auth_serializers.py b/apps/account/serializers/auth_serializers.py index bad2ea7..5724e44 100644 --- a/apps/account/serializers/auth_serializers.py +++ b/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) diff --git a/apps/account/urls_auth.py b/apps/account/urls_auth.py index 6513548..a63f4c7 100644 --- a/apps/account/urls_auth.py +++ b/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'), ] + diff --git a/apps/account/views/auth_views.py b/apps/account/views/auth_views.py index c8bf793..868aee7 100644 --- a/apps/account/views/auth_views.py +++ b/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() diff --git a/config/settings/base.py b/config/settings/base.py index 6692f7c..a2b0d56 100644 --- a/config/settings/base.py +++ b/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": _("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": _("System Settings"), + "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"), }, ], }, diff --git a/config/settings/local.py b/config/settings/local.py index 6ab5e91..0ba8718 100644 --- a/config/settings/local.py +++ b/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, } } } diff --git a/manage.py b/manage.py index 5b01126..889980a 100644 --- a/manage.py +++ b/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: diff --git a/utils/admin.py b/utils/admin.py index 97a0d6d..62dc3eb 100644 --- a/utils/admin.py +++ b/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() - return self._instance.register(model_or_iterable, admin_class, **options) + 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