From f8c4620e446b9ff01252b02dd48bce77b2c0f372 Mon Sep 17 00:00:00 2001 From: sina_sajjadi Date: Sat, 10 Oct 2026 10:43:33 +0330 Subject: [PATCH] feat: implement authentication serializers and related account and meeting management modules --- apps/account/admin/access_modal_widget.py | 2090 +++++++++++++++++ apps/account/admin/user.py | 43 +- apps/account/admin/widgets.py | 82 + ...naged_regions_user_regional_permissions.py | 33 + apps/account/models/user.py | 88 + apps/account/serializers/auth_serializers.py | 19 + apps/geo_map/views.py | 11 +- apps/meetings/admin.py | 2 + .../0002_meetingrequest_rejection_reason.py | 18 + apps/meetings/models/meeting.py | 1 + .../serializers/meeting_serializers.py | 26 + apps/meetings/tests/test_phase7_meetings.py | 34 + apps/meetings/views/meeting_views.py | 2 +- apps/profiles/admin.py | 82 +- .../0002_region_and_institution_region.py | 137 ++ apps/profiles/models/__init__.py | 3 + apps/profiles/models/institution.py | 26 +- apps/profiles/models/region.py | 84 + apps/profiles/permissions.py | 8 +- apps/profiles/serializers/__init__.py | 5 + .../serializers/institution_serializers.py | 94 +- apps/profiles/tests/test_phase2_profiles.py | 124 +- apps/profiles/tests/test_regional_admin.py | 256 ++ apps/profiles/urls.py | 6 + apps/profiles/views/__init__.py | 5 + apps/profiles/views/institution_views.py | 134 +- apps/projects/models/project.py | 13 +- apps/projects/permissions.py | 2 +- .../serializers/project_serializers.py | 35 + config/settings/base.py | 5 + dump.rdb | Bin 89 -> 89 bytes schema.yml | 388 ++- 32 files changed, 3818 insertions(+), 38 deletions(-) create mode 100644 apps/account/admin/access_modal_widget.py create mode 100644 apps/account/migrations/0006_user_managed_regions_user_regional_permissions.py create mode 100644 apps/meetings/migrations/0002_meetingrequest_rejection_reason.py create mode 100644 apps/profiles/migrations/0002_region_and_institution_region.py create mode 100644 apps/profiles/models/region.py create mode 100644 apps/profiles/tests/test_regional_admin.py diff --git a/apps/account/admin/access_modal_widget.py b/apps/account/admin/access_modal_widget.py new file mode 100644 index 0000000..0374ebc --- /dev/null +++ b/apps/account/admin/access_modal_widget.py @@ -0,0 +1,2090 @@ +import json +from django import forms +from django.contrib.auth.models import Group +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django.utils.translation import get_language, get_language_bidi, gettext as _ + +from apps.account.models.role import Role, PERMISSION_CATEGORIES, ALL_PERMISSION_FIELDS +from apps.profiles.models import Region + +CATEGORY_ICONS = { + "Identity & Profiles": "badge", + "Chat & Communications": "forum", + "CMS & Articles": "article", + "LMS & Academics": "school", + "Meetings": "video_camera_front", + "Events": "event", + "Community Projects": "task_alt", + "Donations & Charity": "volunteer_activism", + "Support & Tickets": "confirmation_number", + "Dynamic Forms": "dynamic_form", + "Diplomacy & Institutional": "public", + "Administration & Security": "admin_panel_settings", +} + +CATEGORY_TITLES_FA = { + "Identity & Profiles": "هویت و پروفایل‌ها", + "Chat & Communications": "پیام‌رسان و ارتباطات", + "CMS & Articles": "مدیریت محتوا و مقالات", + "LMS & Academics": "آموزش و امور علمی", + "Meetings": "جلسات و کنفرانس‌ها", + "Events": "رویدادها و همایش‌ها", + "Community Projects": "پروژه‌های مشارکتی", + "Donations & Charity": "امور خیریه و هدایا", + "Support & Tickets": "پشتیبانی و تیکت‌ها", + "Dynamic Forms": "فرم‌های پویا و نظرسنجی", + "Diplomacy & Institutional": "روابط نهادی و بین‌الملل", + "Administration & Security": "مدیریت و امنیت سیستم", +} + + +class UserAccessManagementWidget(forms.Widget): + """ + State-of-the-art Unfold Admin Access Management Widget. + Supports seamless bilingual localization (English / Persian) with automatic LTR/RTL direction. + """ + + def __init__(self, attrs=None): + super().__init__(attrs) + self.instance = None + + def render(self, name, value, attrs=None, renderer=None): + instance = getattr(self, "instance", None) + current_lang = (get_language() or "en").lower() + is_fa = current_lang.startswith("fa") + is_bidi = get_language_bidi() + layout_dir = "rtl" if is_bidi else "ltr" + + t = { + # Overview Card + "overview_title": "خلاصه سطح دسترسی و اختیارات کاربر" if is_fa else "Role & Permissions Overview", + "overview_sub": "تنظیمات یکپارچه نقش سازمانی، دسترسی‌های اختصاصی، نهاد وابسته و مناطق جغرافیایی" if is_fa else "Unified management of user role, custom permissions, affiliated institution, and managed regions", + "btn_add_new_perm": "+ افزودن دسترسی جدید" if is_fa else "+ Add Permission", + "btn_open_modal": "تنظیم نقش و دسترسی‌ها (مودال کامل)" if is_fa else "Configure Role & Access", + "base_role_label": "نقش سازمانی / Base Role" if is_fa else "Base Role", + "btn_change_role": "تغییر نقش" if is_fa else "Change Role", + "unassigned_role": "بدون نقش سازمانی" if is_fa else "Unassigned / No Role", + "all_perms_match": "تمامی اختیارات منطبق بر نقش پایه" if is_fa else "All permissions match base role", + "custom_perms_suffix": "دسترسی اختصاصی فراتر از نقش" if is_fa else "custom permissions beyond role", + "revoked_perms_suffix": "اختیار سلب‌شده" if is_fa else "permissions revoked", + "additional_perms_label": "دسترسی‌های فراتر از نقش / Additional" if is_fa else "Additional Permissions", + "btn_add_perm": "افزودن دسترسی" if is_fa else "Add Permission", + "no_custom_perms": "فاقد مجوز اختصاصی (دسترسی استاندارد)" if is_fa else "No custom permissions (Standard role access)", + "more_suffix": "دیگر" if is_fa else "more", + "add_perm_hint": "روی دکمه «افزودن دسترسی» کلیک کنید تا اختیارات اختصاصی اضافه شود." if is_fa else "Click 'Add Permission' to grant additional custom permissions.", + "institution_label": "نهاد وابسته / Institution" if is_fa else "Affiliated Institution", + "no_institution": "فاقد نهاد وابسته (شخصی/مستقل)" if is_fa else "No affiliated institution (Independent/Personal)", + "institution_hint": "اختیارات اداری درون‌نهادی در این مرکز اعمال می‌شود." if is_fa else "Administrative authority within this institution applies here.", + "regions_label": "قلمرو جغرافیایی / Managed Regions" if is_fa else "Geographic Jurisdictions (Managed Regions)", + "regions_unlimited": "دسترسی عمومی / نامحدود" if is_fa else "Global / Unlimited access", + "regions_hint": "برای مدیران منطقه‌ای به استان‌ها یا کشورهای خاص محدود می‌شود." if is_fa else "For regional administrators, authority is restricted to assigned regions.", + "loading": "در حال بارگذاری..." if is_fa else "Loading...", + + # Modal Dialog + "modal_title": "پیکربندی جامع دسترسی و اختیارات" if is_fa else "Configure Role & Permissions", + "modal_sub": "تخصیص نقش سازمانی، مدیریت اختیارات اختصاصی، تعیین نهاد و حوزه‌های منطقه‌ای" if is_fa else "Assign organizational role, configure custom permissions, set affiliated institution and geographic regions", + "tab_roles": "نقش‌ها و نوع کاربر" if is_fa else "Roles & User Type", + "tab_permissions": "مجوزها و اختیارات" if is_fa else "Permissions & Matrix", + "tab_institutions": "دسترسی به نهادها" if is_fa else "Institution Access", + "tab_regions": "دسترسی به مناطق" if is_fa else "Region Access", + + # Tab 1 + "select_role_title": "انتخاب نقش پایه سازمانی (Assigned Role)" if is_fa else "Select Base Organizational Role", + "select_role_sub": "نقش پایه شامل دسته‌ای از اختیارات پیش‌فرض تاییدشده است" if is_fa else "Base role includes a bundle of verified default permissions", + "default_perms_label": "مجوزهای پیش‌فرض موجود در نقش انتخابی:" if is_fa else "Default permissions granted by selected role:", + "active_perms_count": "مجوز فعال" if is_fa else "active permissions", + "no_role_selected": "هیچ نقشی انتخاب نشده است." if is_fa else "No role selected.", + "role_hint": "برای اعطای مجوزهای بیشتر به کاربر (فراتر از این نقش) یا سلب برخی اختیارات، به تب «مجوزها و اختیارات» مراجعه کنید." if is_fa else "To grant additional permissions (beyond this role) or revoke specific ones, navigate to the 'Permissions & Matrix' tab.", + "default_role_desc": "نقش سازمانی با مجوزهای پیش‌فرض" if is_fa else "Organizational role with default permissions", + "default_perms_chip": "مجوز پیش‌فرض" if is_fa else "default permissions", + "user_type_label": "نوع کاربری (User Type)" if is_fa else "User Type", + "groups_label": "گروه‌های سیستمی (Django Groups)" if is_fa else "Django Groups", + + # User types + "ut_superadmin": "Superadmin (مدیر ارشد سامانه)" if is_fa else "Superadmin (System Administrator)", + "ut_regional_admin": "Regional Admin (مدیر منطقه‌ای)" if is_fa else "Regional Admin", + "ut_institution_admin": "Institution Admin (مدیر نهاد)" if is_fa else "Institution Admin", + "ut_editor": "Editor (ویرایشگر محتوا)" if is_fa else "Editor (Content Editor)", + "ut_staff": "Staff (کارشناس پشتیبانی و امور اداری)" if is_fa else "Staff (Support & Operations)", + "ut_client": "Client (کاربر عمومی سامانه)" if is_fa else "Client (Standard User)", + + # Tab 2 + "matrix_title": "ماتریس دسترسی‌ها و شخصی‌سازی مجوزها" if is_fa else "Permissions Matrix & Customization", + "matrix_sub": "مجوزهای نقش پایه به‌صورت خودکار ارث‌بری می‌شوند. می‌توانید مجوز جدید اضافه کنید یا اختیاری را سلب فرمایید." if is_fa else "Permissions from the base role are automatically inherited. You can grant custom permissions or revoke existing ones.", + "search_placeholder": "جستجو در مجوزها..." if is_fa else "Search permissions...", + "reset_btn": "پاکسازی" if is_fa else "Reset Overrides", + "add_perm_panel_title": "افزودن دسترسی جدید به کاربر (Add New Permission)" if is_fa else "Add New Permission to User", + "add_perm_panel_sub": "یک مجوز سیستمی را انتخاب یا شناسه اختصاصی وارد کنید" if is_fa else "Select a system permission or enter a custom key", + "select_system_perm_default": "-- انتخاب از تمام مجوزهای سیستم --" if is_fa else "-- Select from all system permissions --", + "in_role_tag": " (در نقش موجود است)" if is_fa else " (in role)", + "active_custom_tag": " (فعال اختصاصی)" if is_fa else " (active custom)", + "btn_grant": "اعطای مجوز" if is_fa else "Grant Permission", + "custom_key_placeholder": "یا وارد کردن شناسه مجوز دلخواه (مثال: can_export_reports)..." if is_fa else "Or enter custom permission key (e.g. can_export_reports)...", + "btn_add_key": "افزودن کلید" if is_fa else "Add Key", + "custom_flags_title": "مجوزهای سفارشی افزوده‌شده (Custom Granted Flags)" if is_fa else "Custom Granted Flags", + "delete_perm_title": "حذف این مجوز" if is_fa else "Delete this permission", + "status_custom_allow": "✓ مجوز اختصاصی (Allow)" if is_fa else "✓ Custom Granted (Allow)", + "status_custom_deny": "✕ سلب اختیار (Deny)" if is_fa else "✕ Revoked (Deny)", + "status_inherited_active": "● ارث‌بری: فعال از نقش" if is_fa else "● Inherited: Active from role", + "status_inherited_inactive": "○ ارث‌بری: غیرفعال" if is_fa else "○ Inherited: Inactive", + "status_custom_flag_allow": "✓ مجوز فعال سفارشی" if is_fa else "✓ Custom active permission", + "status_custom_flag_deny": "✕ سلب اختیار سفارشی" if is_fa else "✕ Custom revoked permission", + "btn_inherit": "ارث‌بری" if is_fa else "Inherit", + "btn_allow": "مجوز" if is_fa else "Allow", + "btn_deny": "سلب" if is_fa else "Deny", + + # Tab 3 + "inst_title": "نهاد وابسته به کاربر (Affiliated Institution)" if is_fa else "Affiliated Institution", + "inst_sub": "نام مرکز، موسسه، دانشگاه یا حوزه علمیه‌ای که کاربر به نمایندگی از آن فعالیت می‌کند." if is_fa else "The organization, center, university, or seminary represented by this user.", + "inst_input_label": "عنوان رسمی نهاد" if is_fa else "Official Institution Name", + "inst_input_placeholder": "مثال: جامعة المصطفی العالمیة / دانشگاه بین‌المللی اهل‌بیت (ع)" if is_fa else "e.g., Al-Mustafa International University", + "inst_guidelines_title": "راهنمای اختیارات درون‌نهادی:" if is_fa else "Internal Institution Guidelines:", + "inst_guideline_1": "کاربران دارای نوع کاربری Institution Admin به‌طور خودکار مدیر صفحه و پروژه‌های این نهاد خواهند بود." if is_fa else "Users with Institution Admin user type automatically manage this institution's profile and projects.", + "inst_guideline_2": "برای عضویت چندگانه در نهادها، می‌توانید از بخش پایین صفحه (Institution Memberships Inline) استفاده کنید." if is_fa else "For multiple institution memberships, use the Institution Memberships inline below.", + + # Tab 4 + "regions_title": "تخصیص حوزه‌های صلاحیت جغرافیایی (Managed Regions)" if is_fa else "Assign Geographic Jurisdictions (Managed Regions)", + "regions_sub": "اختیارات نظارتی مدیران منطقه‌ای صرفاً بر روی نهادها، کاربران و رویدادهای واقع در مناطق انتخاب‌شده اعمال می‌گردد." if is_fa else "Oversight authority of regional admins applies only to institutions, users, and events within selected regions.", + "regions_selected_count": "منطقه انتخاب‌شده" if is_fa else "regions selected", + + # Footer + "footer_role": "نقش:" if is_fa else "Role:", + "footer_no_role": "بدون نقش" if is_fa else "No Role", + "footer_extra": "دسترسی اختصاصی" if is_fa else "custom permissions", + "footer_regions": "حوزه منطقه‌ای" if is_fa else "geographic regions", + "btn_cancel": "انصراف" if is_fa else "Cancel", + "btn_apply": "ثبت و اعمال تغییرات" if is_fa else "Save & Apply Changes", + } + + if isinstance(value, str): + try: + overrides = json.loads(value) if value.strip() else {} + except Exception: + overrides = {} + elif isinstance(value, dict): + overrides = value + else: + overrides = {} + + # 1. Fetch Roles Data + roles_data = {} + roles_list = [] + for r in Role.objects.all().order_by("name"): + r_dict = { + "id": str(r.id), + "name": r.name, + "slug": r.slug or "", + "description": r.description or "", + "is_default": r.is_default, + "is_system": r.is_system, + "permissions": r.get_permissions_dict(), + } + roles_data[str(r.id)] = r_dict + roles_list.append(r_dict) + + # 2. Fetch Categories & Permissions Data + categories_data = [] + all_perms_map = {} + for cat_label, field_names in PERMISSION_CATEGORIES.items(): + cat_name_str = str(cat_label) + icon = CATEGORY_ICONS.get(cat_name_str, "admin_panel_settings") + perms_list = [] + for field_name in field_names: + try: + f = Role._meta.get_field(field_name) + vname = str(f.verbose_name) + help_txt = str(f.help_text) if hasattr(f, "help_text") else "" + except Exception: + vname = field_name.replace("_", " ").title() + help_txt = "" + p_item = { + "name": field_name, + "label": vname, + "help": help_txt, + "category": cat_name_str, + } + perms_list.append(p_item) + all_perms_map[field_name] = p_item + + display_title = CATEGORY_TITLES_FA.get(cat_name_str, cat_name_str) if is_fa else cat_name_str + categories_data.append({ + "title": display_title, + "icon": icon, + "perms": perms_list, + }) + + # 3. Fetch Regions Data + regions_data = [] + try: + for reg in Region.objects.filter(is_active=True).order_by("name"): + regions_data.append({ + "id": str(reg.id), + "name": reg.name, + "code": getattr(reg, "code", "") or "", + }) + except Exception: + pass + + # 4. Fetch Groups Data + groups_data = [] + try: + for grp in Group.objects.all().order_by("name"): + groups_data.append({ + "id": str(grp.id), + "name": grp.name, + }) + except Exception: + pass + + # 5. Extract Current User State + current_role_id = str(instance.role_id) if instance and instance.role_id else "" + current_user_type = instance.user_type if instance and hasattr(instance, "user_type") else "client" + current_institution = instance.institution_name or "" if instance and hasattr(instance, "institution_name") else "" + current_groups = [str(g.id) for g in instance.groups.all()] if instance and instance.pk else [] + current_regions = [str(r.id) for r in instance.managed_regions.all()] if instance and instance.pk else [] + + current_role = roles_data.get(current_role_id) + current_role_display = current_role["name"] if current_role else t["unassigned_role"] + current_inst_display = current_institution if current_institution else t["no_institution"] + + initial_state = { + "roleId": current_role_id, + "userType": current_user_type, + "institution": current_institution, + "groups": current_groups, + "regions": current_regions, + "overrides": overrides, + } + + roles_json_str = json.dumps(roles_data) + categories_json_str = json.dumps(categories_data) + regions_json_str = json.dumps(regions_data) + groups_json_str = json.dumps(groups_data) + initial_json_str = json.dumps(initial_state) + perms_map_json_str = json.dumps(all_perms_map) + t_json_str = json.dumps(t) + + # ─── Render HTML Structure ────────────────────────────────────────── + html = [ + f''' +
+ + + + + + + + + + + + + + + +
+ +
+
+
+ shield_person +
+
+

{t["overview_title"]}

+

{t["overview_sub"]}

+
+
+ + +
+ + + + + +
+
+ + +
+ +
+
+ {t["base_role_label"]} + +
+
+ + badge + {escape(current_role_display)} + +
+
+ +
+
+ + +
+
+ {t["additional_perms_label"]} + +
+
+ {t["loading"]} +
+ + {t["add_perm_hint"]} + +
+ + +
+ {t["institution_label"]} +
+ apartment + {escape(current_inst_display)} +
+ + {t["institution_hint"]} + +
+ + +
+ {t["regions_label"]} +
+ {t["loading"]} +
+ + {t["regions_hint"]} + +
+
+
+ + + + + + + +
+
+ + +
+
+
+ admin_panel_settings +
+
+

{t["modal_title"]}

+

{t["modal_sub"]}

+
+
+ +
+ + +
+
+ + + + +
+
+ + + + + + + +
+
+
+ + + + + + ''' + ] + + return mark_safe("\n".join(html)) diff --git a/apps/account/admin/user.py b/apps/account/admin/user.py index a3ef984..210eaf9 100644 --- a/apps/account/admin/user.py +++ b/apps/account/admin/user.py @@ -41,10 +41,23 @@ class UserAdminCreationForm(UserCreationForm): import json -from apps.account.admin.widgets import GroupPillSelectMultipleWidget, RolePermissionsMatrixWidget +from apps.account.admin.widgets import ( + GroupPillSelectMultipleWidget, + RegionPillSelectMultipleWidget, + RolePermissionsMatrixWidget, + UserAccessManagementWidget, +) +from apps.profiles.models import Region class UserAdminChangeForm(UserChangeForm): + managed_regions = forms.ModelMultipleChoiceField( + queryset=Region.objects.filter(is_active=True), + widget=RegionPillSelectMultipleWidget, + required=False, + label=_("Managed Regions"), + help_text=_("Geographic regions assigned to this administrator. Administrative jurisdiction applies only to institutions and projects within these regions.") + ) groups = forms.ModelMultipleChoiceField( queryset=Group.objects.all(), widget=GroupPillSelectMultipleWidget, @@ -52,9 +65,9 @@ class UserAdminChangeForm(UserChangeForm): label=_("Assigned Groups") ) custom_permissions = forms.CharField( - widget=RolePermissionsMatrixWidget, + widget=UserAccessManagementWidget, required=False, - label=_("Application Permissions Matrix") + label=_("Role & Permissions Overview") ) class Meta(UserChangeForm.Meta): @@ -64,6 +77,8 @@ class UserAdminChangeForm(UserChangeForm): super().__init__(*args, **kwargs) if 'email' in self.fields: self.fields['email'].required = True + if 'custom_permissions' in self.fields: + self.fields['custom_permissions'].widget.instance = self.instance def clean_custom_permissions(self): val = self.cleaned_data.get('custom_permissions') @@ -82,7 +97,7 @@ class UserAdmin(ModelAdmin, BaseUserAdmin): add_form = UserAdminCreationForm change_password_form = AdminPasswordChangeForm compressed_fields = False - list_display = ('first_name', 'last_name', 'fullname', 'email', 'role', 'user_type', 'is_staff', 'is_active', 'display_date_joined',) + list_display = ('first_name', 'last_name', 'fullname', 'email', 'role', 'user_type', 'managed_regions_display', 'is_staff', 'is_active', 'display_date_joined',) list_display_links = ('first_name', 'last_name', 'fullname', 'email') ordering = ("-id",) search_fields = ('email', 'first_name', 'last_name', 'fullname', 'username',) @@ -90,6 +105,7 @@ class UserAdmin(ModelAdmin, BaseUserAdmin): list_filter = [ "role", "user_type", + "managed_regions", "is_active", "is_staff", ("last_login", RangeDateTimeFilter), @@ -141,10 +157,11 @@ class UserAdmin(ModelAdmin, BaseUserAdmin): (_("Access & Role"), { "classes": ["tab"], "fields": ( - ("role", "user_type"), ("is_active", "is_staff", "is_superuser"), - "groups", "custom_permissions", + ("role", "user_type"), + "groups", + "managed_regions", ), }), (_("Raw Database Permissions"), { @@ -169,6 +186,20 @@ class UserAdmin(ModelAdmin, BaseUserAdmin): def display_date_joined(self, instance: User): return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-" + @display(description=_("Managed Regions")) + def managed_regions_display(self, instance: User): + regions = instance.managed_regions.all() + if not regions.exists(): + if instance.user_type == 'regional_admin': + return format_html('{}', _("None (Unassigned)")) + return "-" + names = [r.name for r in regions[:3]] + text = ", ".join(names) + if regions.count() > 3: + text += f" (+{regions.count() - 3})" + return text + + class GroupAdmin(BaseGroupAdmin, ModelAdmin): list_display = ('name', 'permissions_count') diff --git a/apps/account/admin/widgets.py b/apps/account/admin/widgets.py index 243c6b9..f54942c 100644 --- a/apps/account/admin/widgets.py +++ b/apps/account/admin/widgets.py @@ -4,6 +4,7 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from apps.account.models.role import Role, PERMISSION_CATEGORIES, ALL_PERMISSION_FIELDS +from apps.account.admin.access_modal_widget import UserAccessManagementWidget CATEGORY_ICONS = { @@ -102,6 +103,87 @@ class GroupPillSelectMultipleWidget(forms.CheckboxSelectMultiple): return mark_safe('\n'.join(output)) +class RegionPillSelectMultipleWidget(forms.CheckboxSelectMultiple): + """ + Renders Regions as modern, interactive clickable pill cards with map/public icons. + Allows easy point-and-click assignment of managed regions to administrators. + """ + def render(self, name, value, attrs=None, renderer=None): + if value is None: + value = [] + elif isinstance(value, str): + value = [value] + else: + value = [str(v.pk if hasattr(v, 'pk') else v) for v in value] + + attrs = attrs or {} + output = [ + '
', + '
' + ] + + has_choices = False + for option_value, option_label in self.choices: + if not option_value: + continue + has_choices = True + is_checked = str(option_value) in value + checked_attr = 'checked' if is_checked else '' + + card_class = ( + 'region-pill-card cursor-pointer select-none inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border text-sm transition-all duration-150 ' + + ('bg-emerald-50 border-emerald-400 text-emerald-700 shadow-xs dark:bg-emerald-950/60 dark:border-emerald-600 dark:text-emerald-300 font-semibold' + if is_checked else 'bg-white border-slate-200 text-slate-700 hover:bg-slate-50 dark:bg-slate-900 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800') + ) + icon_name = 'check_circle' if is_checked else 'public' + icon_class = 'material-symbols-outlined text-base pill-icon ' + ('text-emerald-600 dark:text-emerald-400' if is_checked else 'text-slate-400') + + output.append( + f'' + ) + + if not has_choices: + output.append('No regions available in system.') + + output.append(''' +
+
+ Click any region chip to assign or unassign geographic jurisdiction for this regional admin. +
+
+ + ''') + return mark_safe('\n'.join(output)) + + + class RolePermissionsMatrixWidget(forms.Widget): """ Renders an interactive, categorized visual permissions matrix. diff --git a/apps/account/migrations/0006_user_managed_regions_user_regional_permissions.py b/apps/account/migrations/0006_user_managed_regions_user_regional_permissions.py new file mode 100644 index 0000000..435da4c --- /dev/null +++ b/apps/account/migrations/0006_user_managed_regions_user_regional_permissions.py @@ -0,0 +1,33 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0005_role_user_custom_permissions_user_role'), + ('profiles', '0002_region_and_institution_region'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='managed_regions', + field=models.ManyToManyField( + blank=True, + help_text='Regions assigned to this regional administrator or staff member. Administrative actions and regional permissions apply only to these regions.', + related_name='regional_admins', + to='profiles.region', + verbose_name='Managed Regions', + ), + ), + migrations.AddField( + model_name='user', + name='regional_permissions', + field=models.JSONField( + blank=True, + default=list, + help_text="List of permission names that are restricted to the user's managed regions (e.g. ['can_manage_institutions', 'can_manage_tickets']).", + verbose_name='Regional Permission Overrides', + ), + ), + ] diff --git a/apps/account/models/user.py b/apps/account/models/user.py index d7ba04c..0b24664 100644 --- a/apps/account/models/user.py +++ b/apps/account/models/user.py @@ -160,6 +160,19 @@ class User(AbstractUser): verbose_name=_("Custom Permission Overrides"), help_text=_("Dictionary of permission overrides: {permission_name: True/False}") ) + managed_regions = models.ManyToManyField( + 'profiles.Region', + blank=True, + related_name='regional_admins', + verbose_name=_("Managed Regions"), + help_text=_("Regions assigned to this regional administrator or staff member. Administrative actions and regional permissions apply only to these regions.") + ) + regional_permissions = models.JSONField( + default=list, + blank=True, + verbose_name=_("Regional Permission Overrides"), + help_text=_("List of permission names that are restricted to the user's managed regions (e.g. ['can_manage_institutions', 'can_manage_tickets']).") + ) objects = UserManager() @@ -254,6 +267,81 @@ class User(AbstractUser): def is_regional_admin(self): return self.is_super_admin or self.user_type == self.UserType.REGIONAL_ADMIN or (self.role and self.role.slug == 'regional_admin') + def can_manage_region(self, region) -> bool: + """ + Check if user has administrative rights over the specified region: + - Super admins have global authority over all regions. + - Regional admins / staff only have authority over their assigned managed_regions. + - If no region is provided or user has no region rights, returns False. + """ + if not self.is_authenticated or not self.is_active: + return False + if self.is_superuser or self.is_super_admin: + return True + if not (self.is_regional_admin or self.has_perm_flag('can_manage_institutions') or self.managed_regions.exists()): + return False + if not region: + return False + region_id = region.id if hasattr(region, 'id') else region + return self.managed_regions.filter(id=region_id).exists() + + def has_regional_perm(self, perm_name: str, region=None) -> bool: + """ + Check if user has a permission, optionally scoped to a region: + - Super admin: always True. + - If region is None: True if user holds the permission flag in general. + - If region is provided: + * Must have the permission flag (or be a regional admin). + * Must have authority over that specific region via can_manage_region(region). + * If regional_permissions is configured, checks permission in dict/list. + """ + if not self.is_authenticated or not self.is_active: + return False + if self.is_superuser or self.is_super_admin: + return True + + has_flag = self.has_perm_flag(perm_name) or self.is_regional_admin + if not has_flag: + return False + + if self.regional_permissions: + allowed = False + # Check 1: direct key in dict (e.g. {"institutions.change": True} or {"can_manage_institutions": True}) + if isinstance(self.regional_permissions, dict): + if perm_name in self.regional_permissions: + val = self.regional_permissions[perm_name] + allowed = bool(val) + # Check 2: category-action format (e.g. "institutions.change" -> category "institutions", action "change") + elif '.' in perm_name: + cat, action = perm_name.split('.', 1) + if cat in self.regional_permissions: + cat_perms = self.regional_permissions[cat] + if isinstance(cat_perms, (list, tuple, set)): + allowed = action in cat_perms + elif isinstance(cat_perms, dict): + allowed = bool(cat_perms.get(action)) + elif isinstance(cat_perms, bool): + allowed = cat_perms + elif isinstance(self.regional_permissions, (list, tuple, set)): + allowed = perm_name in self.regional_permissions + + if not allowed: + return False + + if region is not None: + return self.can_manage_region(region) + + return self.is_regional_admin or self.managed_regions.exists() + + + def get_managed_regions_queryset(self): + from apps.profiles.models import Region + if self.is_superuser or self.is_super_admin: + return Region.objects.all() + if self.is_regional_admin or self.managed_regions.exists(): + return self.managed_regions.all() + return Region.objects.none() + @property def is_institution_admin(self): return self.is_regional_admin or self.user_type == self.UserType.INSTITUTION_ADMIN or (self.role and self.role.slug in ['institution_admin', 'institution_owner']) diff --git a/apps/account/serializers/auth_serializers.py b/apps/account/serializers/auth_serializers.py index d9231cf..8eb7ed6 100644 --- a/apps/account/serializers/auth_serializers.py +++ b/apps/account/serializers/auth_serializers.py @@ -94,6 +94,8 @@ class UserMeSerializer(serializers.ModelSerializer): bio = serializers.CharField(source='info', allow_blank=True, required=False, allow_null=True) roles = serializers.SerializerMethodField() permissions = serializers.SerializerMethodField() + managed_regions = serializers.SerializerMethodField() + regional_permissions = serializers.ReadOnlyField() verification_status = serializers.SerializerMethodField() is_verified = serializers.SerializerMethodField() first_name = serializers.SerializerMethodField() @@ -143,6 +145,8 @@ class UserMeSerializer(serializers.ModelSerializer): 'is_staff', 'roles', 'permissions', + 'managed_regions', + 'regional_permissions', 'verification_status', 'is_verified', ] @@ -168,6 +172,8 @@ class UserMeSerializer(serializers.ModelSerializer): 'is_staff', 'roles', 'permissions', + 'managed_regions', + 'regional_permissions', 'verification_status', 'is_verified', ] @@ -268,6 +274,19 @@ class UserMeSerializer(serializers.ModelSerializer): def get_is_verified(self, obj) -> bool: return obj.verification_status == VerificationDocument.Status.VERIFIED + def get_managed_regions(self, obj) -> list: + if not getattr(obj, 'is_authenticated', False): + return [] + return [ + { + 'id': r.id, + 'name': r.name, + 'slug': r.slug, + 'code': r.code, + } + for r in obj.managed_regions.filter(is_active=True) + ] + class UserMeUpdateSerializer(serializers.ModelSerializer): bio = serializers.CharField(source='info', allow_blank=True, required=False, allow_null=True) diff --git a/apps/geo_map/views.py b/apps/geo_map/views.py index 54490e4..7aea773 100644 --- a/apps/geo_map/views.py +++ b/apps/geo_map/views.py @@ -63,6 +63,13 @@ class MapInstitutionsView(GenericAPIView): if city: qs = qs.filter(city__iexact=city) + region = request.query_params.get('region') + if region: + if region.isdigit(): + qs = qs.filter(region_id=int(region)) + else: + qs = qs.filter(Q(region__slug=region) | Q(region__name__iexact=region)) + inst_type = request.query_params.get('type') if inst_type: qs = qs.filter(type=inst_type) @@ -103,7 +110,7 @@ class MapInstitutionsView(GenericAPIView): # Format items institutions_list = [] - for inst in qs.only('id', 'name', 'slug', 'type', 'latitude', 'longitude', 'city', 'country', 'avatar', 'cover_image', 'is_featured', 'verification_status', 'follower_count'): + for inst in qs.select_related('region').only('id', 'name', 'slug', 'type', 'latitude', 'longitude', 'city', 'country', 'avatar', 'cover_image', 'is_featured', 'verification_status', 'follower_count', 'region'): avatar_url = inst.avatar.url if inst.avatar else None cover_url = inst.cover_image.url if inst.cover_image else None institutions_list.append({ @@ -112,6 +119,8 @@ class MapInstitutionsView(GenericAPIView): "slug": inst.slug, "type": inst.type, "type_display": inst.get_type_display(), + "region": inst.region_id, + "region_name": inst.region.name if inst.region else None, "lat": inst.latitude, "lng": inst.longitude, "city": inst.city, diff --git a/apps/meetings/admin.py b/apps/meetings/admin.py index aefd8eb..553582d 100644 --- a/apps/meetings/admin.py +++ b/apps/meetings/admin.py @@ -22,6 +22,8 @@ class MeetingRequestAdmin(ModelAdmin): search_fields = ( 'title', 'agenda', + 'admin_notes', + 'rejection_reason', 'requester_institution__name', 'recipient_institution__name', 'created_by__email', diff --git a/apps/meetings/migrations/0002_meetingrequest_rejection_reason.py b/apps/meetings/migrations/0002_meetingrequest_rejection_reason.py new file mode 100644 index 0000000..b632fb0 --- /dev/null +++ b/apps/meetings/migrations/0002_meetingrequest_rejection_reason.py @@ -0,0 +1,18 @@ +# Generated manually on 2026-10-08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meetings', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='meetingrequest', + name='rejection_reason', + field=models.TextField(blank=True, verbose_name='Rejection / Decline Reason'), + ), + ] diff --git a/apps/meetings/models/meeting.py b/apps/meetings/models/meeting.py index e893830..77259e8 100644 --- a/apps/meetings/models/meeting.py +++ b/apps/meetings/models/meeting.py @@ -44,6 +44,7 @@ class MeetingRequest(models.Model): meeting_url = models.URLField(max_length=500, blank=True, verbose_name="Video Meeting Link") agenda = models.TextField(blank=True, verbose_name="Meeting Agenda & Topics") admin_notes = models.TextField(blank=True, verbose_name="Internal / Admin Notes") + rejection_reason = models.TextField(blank=True, verbose_name="Rejection / Decline Reason") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/meetings/serializers/meeting_serializers.py b/apps/meetings/serializers/meeting_serializers.py index 0bbc4ce..82ea289 100644 --- a/apps/meetings/serializers/meeting_serializers.py +++ b/apps/meetings/serializers/meeting_serializers.py @@ -34,6 +34,7 @@ class MeetingListSerializer(serializers.ModelSerializer): 'meeting_time', 'duration_minutes', 'meeting_url', + 'rejection_reason', 'created_at', ] @@ -58,6 +59,7 @@ class MeetingDetailSerializer(serializers.ModelSerializer): 'meeting_url', 'agenda', 'admin_notes', + 'rejection_reason', 'created_at', 'updated_at', ] @@ -117,6 +119,12 @@ class MeetingCreateSerializer(serializers.ModelSerializer): class MeetingUpdateSerializer(serializers.ModelSerializer): + rejection_reason = serializers.CharField( + required=False, + allow_blank=True, + help_text="Required explanation when rejecting/declining a meeting request." + ) + class Meta: model = MeetingRequest fields = [ @@ -127,4 +135,22 @@ class MeetingUpdateSerializer(serializers.ModelSerializer): 'meeting_url', 'agenda', 'admin_notes', + 'rejection_reason', ] + + def validate(self, attrs): + status_val = attrs.get('status') + if status_val == MeetingStatus.DECLINED: + reason = (attrs.get('rejection_reason') or attrs.get('admin_notes') or '').strip() + if not reason: + if self.instance and self.instance.rejection_reason: + reason = self.instance.rejection_reason + else: + raise serializers.ValidationError({ + "rejection_reason": "An explanation or reason is required when declining a meeting request." + }) + attrs['rejection_reason'] = reason + if not attrs.get('admin_notes'): + attrs['admin_notes'] = reason + + return attrs diff --git a/apps/meetings/tests/test_phase7_meetings.py b/apps/meetings/tests/test_phase7_meetings.py index 03ae396..54a8c7e 100644 --- a/apps/meetings/tests/test_phase7_meetings.py +++ b/apps/meetings/tests/test_phase7_meetings.py @@ -191,6 +191,40 @@ class Phase7MeetingsAPITests(APITestCase): self.assertEqual(response.data['status'], MeetingStatus.SCHEDULED) self.assertEqual(response.data['admin_notes'], "Accepted. Looking forward to meeting.") + def test_decline_meeting_without_explanation_fails(self): + url = reverse('meeting-detail', kwargs={'pk': self.meeting.id}) + self.client.force_authenticate(user=self.admin_b) + response = self.client.patch(url, { + "status": MeetingStatus.DECLINED + }, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + error_fields = [e.get('field') for e in response.data.get('errors', [])] if 'errors' in response.data else response.data + self.assertTrue('rejection_reason' in error_fields) + + def test_decline_meeting_with_rejection_reason_succeeds(self): + url = reverse('meeting-detail', kwargs={'pk': self.meeting.id}) + self.client.force_authenticate(user=self.admin_b) + reason = "Unable to attend due to overlapping diplomatic conference." + response = self.client.patch(url, { + "status": MeetingStatus.DECLINED, + "rejection_reason": reason + }, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], MeetingStatus.DECLINED) + self.assertEqual(response.data['rejection_reason'], reason) + + def test_decline_meeting_with_admin_notes_succeeds(self): + url = reverse('meeting-detail', kwargs={'pk': self.meeting.id}) + self.client.force_authenticate(user=self.admin_b) + note = "Declined due to holiday closure." + response = self.client.patch(url, { + "status": MeetingStatus.DECLINED, + "admin_notes": note + }, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], MeetingStatus.DECLINED) + self.assertEqual(response.data['rejection_reason'], note) + def test_cancel_meeting(self): url = reverse('meeting-detail', kwargs={'pk': self.meeting.id}) self.client.force_authenticate(user=self.admin_a) diff --git a/apps/meetings/views/meeting_views.py b/apps/meetings/views/meeting_views.py index 64b45f0..36b9371 100644 --- a/apps/meetings/views/meeting_views.py +++ b/apps/meetings/views/meeting_views.py @@ -103,7 +103,7 @@ class MeetingListCreateView(generics.ListCreateAPIView): patch=extend_schema( tags=['Meetings'], summary="Update or respond to meeting request", - description="Accept, reschedule, decline, complete, or update meeting room URL and agenda notes.", + description="Accept, reschedule, decline (requires rejection_reason or admin_notes explanation), complete, or update meeting room URL and agenda notes.", request=MeetingUpdateSerializer, responses={200: MeetingDetailSerializer} ), diff --git a/apps/profiles/admin.py b/apps/profiles/admin.py index f748122..cf382d1 100644 --- a/apps/profiles/admin.py +++ b/apps/profiles/admin.py @@ -3,7 +3,8 @@ from django.utils.translation import gettext_lazy as _ from unfold.admin import ModelAdmin, TabularInline, StackedInline from unfold.decorators import display, action -from apps.profiles.models.institution import ( +from apps.profiles.models import ( + Region, Institution, InstitutionMember, InstitutionTimeline, @@ -13,6 +14,44 @@ from apps.profiles.models.institution import ( from utils.admin import project_admin_site +@admin.register(Region, site=project_admin_site) +class RegionAdmin(ModelAdmin): + list_display = ( + 'name', + 'code', + 'slug', + 'is_active', + 'institutions_count', + 'created_at', + ) + list_display_links = ('name', 'code', 'slug') + list_filter = ('is_active', 'created_at') + search_fields = ('name', 'code', 'description') + prepopulated_fields = {'slug': ('name',)} + readonly_fields = ('created_at', 'updated_at', 'institutions_count') + + fieldsets = ( + (_('Region Details'), { + 'fields': ('name', 'slug', 'code', 'is_active', 'description') + }), + (_('Timestamps & Statistics'), { + 'fields': ('institutions_count', 'created_at', 'updated_at') + }), + ) + + @display(description=_('Institutions Count')) + def institutions_count(self, obj): + return obj.institutions.count() + + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser or getattr(request.user, 'is_super_admin', False): + return qs + if getattr(request.user, 'is_regional_admin', False) and hasattr(request.user, 'managed_regions'): + return qs.filter(id__in=request.user.managed_regions.all()) + return qs + + class InstitutionMemberInline(TabularInline): model = InstitutionMember extra = 0 @@ -38,6 +77,7 @@ class InstitutionAdmin(ModelAdmin): list_display = ( 'name', 'type_badge', + 'region_display', 'city', 'country', 'verification_badge', @@ -47,19 +87,49 @@ class InstitutionAdmin(ModelAdmin): 'created_at', ) list_display_links = ('name', 'type_badge', 'city', 'country') - list_filter = ('type', 'verification_status', 'is_featured', 'is_active', 'country') - search_fields = ('name', 'city', 'country', 'description', 'email', 'phone') + list_filter = ('region', 'type', 'verification_status', 'is_featured', 'is_active', 'country') + search_fields = ('name', 'region__name', 'city', 'country', 'description', 'email', 'phone') prepopulated_fields = {'slug': ('name',)} readonly_fields = ('follower_count', 'created_at', 'updated_at') inlines = [InstitutionMemberInline, InstitutionTimelineInline, InstitutionMediaInline] actions = ['approve_institutions', 'reject_institutions', 'toggle_featured'] + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser or getattr(request.user, 'is_super_admin', False): + return qs + if getattr(request.user, 'is_regional_admin', False) and hasattr(request.user, 'managed_regions'): + return qs.filter(region__in=request.user.managed_regions.all()) + return qs + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "region": + if not (request.user.is_superuser or getattr(request.user, 'is_super_admin', False)) and getattr(request.user, 'is_regional_admin', False) and hasattr(request.user, 'managed_regions'): + kwargs["queryset"] = request.user.managed_regions.all() + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def has_change_permission(self, request, obj=None): + if not super().has_change_permission(request, obj): + return False + if obj is not None and not (request.user.is_superuser or getattr(request.user, 'is_super_admin', False)) and getattr(request.user, 'is_regional_admin', False): + if hasattr(request.user, 'can_manage_region'): + return request.user.can_manage_region(obj.region) + return True + + def has_delete_permission(self, request, obj=None): + if not super().has_delete_permission(request, obj): + return False + if obj is not None and not (request.user.is_superuser or getattr(request.user, 'is_super_admin', False)) and getattr(request.user, 'is_regional_admin', False): + if hasattr(request.user, 'can_manage_region'): + return request.user.can_manage_region(obj.region) + return True + fieldsets = ( (_('Identity & Classification'), { 'fields': ('name', 'slug', 'type', 'is_featured', 'is_active', 'created_by') }), (_('Location & Geo-Coordinates'), { - 'fields': ('country', 'city', 'address', 'latitude', 'longitude') + 'fields': ('region', 'country', 'city', 'address', 'latitude', 'longitude') }), (_('Contact & Official Channels'), { 'fields': ('email', 'phone', 'website', 'established_year') @@ -75,6 +145,10 @@ class InstitutionAdmin(ModelAdmin): }), ) + @display(description=_('Region')) + def region_display(self, obj): + return obj.region.name if obj.region else "-" + @display(description=_('Type')) def type_badge(self, obj): return obj.get_type_display() diff --git a/apps/profiles/migrations/0002_region_and_institution_region.py b/apps/profiles/migrations/0002_region_and_institution_region.py new file mode 100644 index 0000000..aa48039 --- /dev/null +++ b/apps/profiles/migrations/0002_region_and_institution_region.py @@ -0,0 +1,137 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def create_initial_regions(apps, schema_editor): + Region = apps.get_model('profiles', 'Region') + regions = [ + {"name": "Global / Other", "slug": "global-other", "code": "GLB", "description": "Global or international scope"}, + {"name": "Middle East & North Africa", "slug": "middle-east-north-africa", "code": "MENA", "description": "Middle East, Gulf countries, and North Africa"}, + {"name": "Europe", "slug": "europe", "code": "EU", "description": "European continent and United Kingdom"}, + {"name": "North America", "slug": "north-america", "code": "NA", "description": "United States and Canada"}, + {"name": "Central & South Asia", "slug": "central-south-asia", "code": "CSA", "description": "Central and South Asian subcontinent"}, + {"name": "East & Southeast Asia", "slug": "east-southeast-asia", "code": "ESA", "description": "East Asia and ASEAN region"}, + {"name": "Latin America & Caribbean", "slug": "latin-america", "code": "LATAM", "description": "Central and South America and the Caribbean"}, + {"name": "Sub-Saharan Africa", "slug": "sub-saharan-africa", "code": "SSA", "description": "Central, Eastern, Western, and Southern Africa"}, + {"name": "Oceania", "slug": "oceania", "code": "OCN", "description": "Australia, New Zealand, and Pacific Islands"}, + ] + for r in regions: + Region.objects.get_or_create( + name=r["name"], + defaults={ + "slug": r["slug"], + "code": r["code"], + "description": r["description"], + "is_active": True, + } + ) + + +def populate_regions_and_backfill(apps, schema_editor): + Region = apps.get_model('profiles', 'Region') + Institution = apps.get_model('profiles', 'Institution') + + default_region = Region.objects.filter(slug="global-other").first() or Region.objects.first() + mena = Region.objects.filter(slug="middle-east-north-africa").first() or default_region + europe = Region.objects.filter(slug="europe").first() or default_region + na = Region.objects.filter(slug="north-america").first() or default_region + csa = Region.objects.filter(slug="central-south-asia").first() or default_region + esa = Region.objects.filter(slug="east-southeast-asia").first() or default_region + latam = Region.objects.filter(slug="latin-america").first() or default_region + ssa = Region.objects.filter(slug="sub-saharan-africa").first() or default_region + oceania = Region.objects.filter(slug="oceania").first() or default_region + + country_map = { + # MENA + "ایران": mena, "iran": mena, "iraq": mena, "عراق": mena, "syria": mena, "سوریه": mena, + "lebanon": mena, "لبنان": mena, "turkey": mena, "ترکیه": mena, "yemen": mena, "یمن": mena, + "saudi arabia": mena, "عربستان": mena, "uae": mena, "united arab emirates": mena, + "امارات": mena, "kuwait": mena, "کویت": mena, "qatar": mena, "قطر": mena, + "bahrain": mena, "بحرین": mena, "oman": mena, "عمان": mena, "jordan": mena, "اردن": mena, + "palestine": mena, "فلسطین": mena, "egypt": mena, "مصر": mena, "morocco": mena, "مراکش": mena, + "algeria": mena, "الجزایر": mena, "tunisia": mena, "تونس": mena, "libya": mena, "لیبی": mena, + # Europe + "russia": europe, "روسیه": europe, "sweden": europe, "سوئد": europe, "germany": europe, "آلمان": europe, + "united kingdom": europe, "uk": europe, "انگلیس": europe, "بریتانیا": europe, "france": europe, "فرانسه": europe, + "italy": europe, "ایتالیا": europe, "austria": europe, "اتریش": europe, "netherlands": europe, "هلند": europe, + "belgium": europe, "بلژیک": europe, "switzerland": europe, "سوئیس": europe, "spain": europe, "اسپانیا": europe, + "norway": europe, "نروژ": europe, "denmark": europe, "دانمارک": europe, "finland": europe, "فنلاند": europe, + "bosnia": europe, "bosnia and herzegovina": europe, "بوسنی": europe, "poland": europe, "لهستان": europe, + # North America + "canada": na, "کانادا": na, "united states": na, "usa": na, "آمریکا": na, "ایالات متحده": na, + # Central & South Asia + "pakistan": csa, "پاکستان": csa, "india": csa, "هند": csa, "هندوستان": csa, + "afghanistan": csa, "افغانستان": csa, "bangladesh": csa, "بنگلادش": csa, + "tajikistan": csa, "تاجیکستان": csa, "uzbekistan": csa, "ازبکستان": csa, + "azerbaijan": csa, "آذربایجان": csa, "turkmenistan": csa, "ترکمنستان": csa, + # East & Southeast Asia + "indonesia": esa, "اندونزی": esa, "malaysia": esa, "مالزی": esa, "china": esa, "چین": esa, + "japan": esa, "ژاپن": esa, "thailand": esa, "تایلند": esa, "philippines": esa, "فیلیپین": esa, + # Latin America + "brazil": latam, "برزیل": latam, "argentina": latam, "آرژانتین": latam, "colombia": latam, "کلمبیا": latam, + "chile": latam, "شیلی": latam, "venezuela": latam, "ونزوئلا": latam, "mexico": latam, "مکزیک": latam, + # Sub-Saharan Africa + "nigeria": ssa, "نیجریه": ssa, "ghana": ssa, "غنا": ssa, "kenya": ssa, "کنیا": ssa, + "tanzania": ssa, "تانزانیا": ssa, "south africa": ssa, "آفریقای جنوبی": ssa, + # Oceania + "australia": oceania, "استرالیا": oceania, "new zealand": oceania, "نیوزیلند": oceania, + } + + for inst in Institution.objects.all(): + c = (inst.country or "").strip().lower() + inst.region = country_map.get(c, default_region) + inst.save(update_fields=['region']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Region', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Name of the geographical or administrative region (e.g. Middle East, Europe).', max_length=255, unique=True, verbose_name='Region Name')), + ('slug', models.SlugField(allow_unicode=True, blank=True, max_length=255, null=True, unique=True, verbose_name='Slug / URL Identifier')), + ('code', models.CharField(blank=True, help_text='Short code such as MENA, EU, NA, etc.', max_length=50, null=True, verbose_name='Region Code')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ], + options={ + 'verbose_name': 'Region', + 'verbose_name_plural': 'Regions', + 'ordering': ('name',), + }, + ), + migrations.RunPython(create_initial_regions, reverse_code=migrations.RunPython.noop), + migrations.AddField( + model_name='institution', + name='region', + field=models.ForeignKey( + blank=True, + help_text='Geographical or administrative region of the institution.', + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='institutions', + to='profiles.region', + verbose_name='Region', + ), + ), + migrations.RunPython(populate_regions_and_backfill, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name='institution', + name='region', + field=models.ForeignKey( + help_text='Geographical or administrative region of the institution.', + on_delete=django.db.models.deletion.PROTECT, + related_name='institutions', + to='profiles.region', + verbose_name='Region', + ), + ), + ] diff --git a/apps/profiles/models/__init__.py b/apps/profiles/models/__init__.py index cdf96a6..bbb7d20 100644 --- a/apps/profiles/models/__init__.py +++ b/apps/profiles/models/__init__.py @@ -1,3 +1,4 @@ +from .region import Region, get_default_region_pk from .institution import ( Institution, InstitutionMember, @@ -7,6 +8,8 @@ from .institution import ( ) __all__ = [ + 'Region', + 'get_default_region_pk', 'Institution', 'InstitutionMember', 'InstitutionTimeline', diff --git a/apps/profiles/models/institution.py b/apps/profiles/models/institution.py index 926416b..273b16e 100644 --- a/apps/profiles/models/institution.py +++ b/apps/profiles/models/institution.py @@ -39,6 +39,13 @@ class Institution(models.Model): default=InstitutionType.CULTURAL_CENTER, verbose_name=_('Institution Type') ) + region = models.ForeignKey( + 'profiles.Region', + on_delete=models.PROTECT, + related_name='institutions', + verbose_name=_('Region'), + help_text=_('Geographical or administrative region of the institution.') + ) country = models.CharField( max_length=255, verbose_name=_('Country') @@ -161,6 +168,10 @@ class Institution(models.Model): return f"{self.name} ({self.city}, {self.country})" def save(self, *args, **kwargs): + if not getattr(self, 'region_id', None): + from apps.profiles.models.region import Region + default_region = Region.get_default_region() + self.region = default_region if not self.slug: base_slug = slugify(self.name, allow_unicode=True) or f"institution-{random.randint(1000, 9999)}" slug = base_slug @@ -174,7 +185,11 @@ class Institution(models.Model): def is_admin(self, user): if not user or not user.is_authenticated: return False - if getattr(user, 'is_super_admin', False) or getattr(user, 'is_regional_admin', False): + if getattr(user, 'is_super_admin', False) or user.is_superuser: + return True + if getattr(user, 'is_regional_admin', False) or (hasattr(user, 'has_perm_flag') and user.has_perm_flag('can_manage_institutions')): + if hasattr(user, 'can_manage_region'): + return user.can_manage_region(self.region) return True return self.members.filter(user=user, role=InstitutionMember.MemberRole.ADMIN).exists() @@ -183,6 +198,9 @@ class Institution(models.Model): return False if self.is_admin(user): return True + if getattr(user, 'is_regional_admin', False) and hasattr(user, 'can_manage_region'): + if user.can_manage_region(self.region): + return True return self.members.filter(user=user, role__in=[InstitutionMember.MemberRole.ADMIN, InstitutionMember.MemberRole.EDITOR]).exists() def get_user_role(self, user): @@ -191,9 +209,13 @@ class Institution(models.Model): member = self.members.filter(user=user).first() if member: return member.role - if getattr(user, 'is_super_admin', False): + if getattr(user, 'is_super_admin', False) or user.is_superuser: return 'super_admin' if getattr(user, 'is_regional_admin', False): + if hasattr(user, 'can_manage_region'): + if user.can_manage_region(self.region): + return 'regional_admin' + return None return 'regional_admin' return None diff --git a/apps/profiles/models/region.py b/apps/profiles/models/region.py new file mode 100644 index 0000000..3e22735 --- /dev/null +++ b/apps/profiles/models/region.py @@ -0,0 +1,84 @@ +import random +from django.db import models +from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ + + +def get_default_region_pk(): + region = Region.get_default_region() + return region.pk if region else None + + +class Region(models.Model): + name = models.CharField( + max_length=255, + unique=True, + verbose_name=_('Region Name'), + help_text=_('Name of the geographical or administrative region (e.g. Middle East, Europe).') + ) + slug = models.SlugField( + max_length=255, + unique=True, + blank=True, + null=True, + allow_unicode=True, + verbose_name=_('Slug / URL Identifier') + ) + code = models.CharField( + max_length=50, + blank=True, + null=True, + verbose_name=_('Region Code'), + help_text=_('Short code such as MENA, EU, NA, etc.') + ) + description = models.TextField( + blank=True, + null=True, + verbose_name=_('Description') + ) + is_active = models.BooleanField( + default=True, + verbose_name=_('Is Active') + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name=_('Created At') + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name=_('Updated At') + ) + + class Meta: + ordering = ('name',) + verbose_name = _('Region') + verbose_name_plural = _('Regions') + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.name, allow_unicode=True) or f"region-{random.randint(1000, 9999)}" + slug = base_slug + counter = 1 + while Region.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + + @classmethod + def get_default_region(cls): + region = cls.objects.filter(name="Global / Other").first() + if not region: + region, _ = cls.objects.get_or_create( + slug="global-other", + defaults={ + "name": "Global / Other", + "code": "GLB", + "description": "Default international / global region", + "is_active": True, + } + ) + return region diff --git a/apps/profiles/permissions.py b/apps/profiles/permissions.py index 958eac6..f15c223 100644 --- a/apps/profiles/permissions.py +++ b/apps/profiles/permissions.py @@ -19,8 +19,8 @@ class IsInstitutionAdminOrReadOnly(BasePermission): if not request.user or not request.user.is_authenticated or not request.user.is_active: return False - # Super admin and regional admin have global write authority - if getattr(request.user, 'is_super_admin', False) or getattr(request.user, 'is_regional_admin', False): + # Super admin has global write authority + if getattr(request.user, 'is_super_admin', False) or request.user.is_superuser: return True # Check if obj is Institution or a related model with an institution foreign key @@ -47,7 +47,7 @@ class IsInstitutionEditorOrReadOnly(BasePermission): if not request.user or not request.user.is_authenticated or not request.user.is_active: return False - if getattr(request.user, 'is_super_admin', False) or getattr(request.user, 'is_regional_admin', False): + if getattr(request.user, 'is_super_admin', False) or request.user.is_superuser: return True institution = obj if isinstance(obj, Institution) else getattr(obj, 'institution', None) @@ -68,7 +68,7 @@ class CanManageInstitutionMembers(BasePermission): if not request.user or not request.user.is_authenticated or not request.user.is_active: return False - if getattr(request.user, 'is_super_admin', False) or getattr(request.user, 'is_regional_admin', False): + if getattr(request.user, 'is_super_admin', False) or request.user.is_superuser: return True institution = obj if isinstance(obj, Institution) else getattr(obj, 'institution', None) diff --git a/apps/profiles/serializers/__init__.py b/apps/profiles/serializers/__init__.py index ff675f7..155cd9c 100644 --- a/apps/profiles/serializers/__init__.py +++ b/apps/profiles/serializers/__init__.py @@ -1,4 +1,6 @@ from .institution_serializers import ( + RegionMiniSerializer, + RegionSerializer, InstitutionTimelineSerializer, InstitutionMediaSerializer, InstitutionMemberSerializer, @@ -10,6 +12,8 @@ from .institution_serializers import ( ) __all__ = [ + 'RegionMiniSerializer', + 'RegionSerializer', 'InstitutionTimelineSerializer', 'InstitutionMediaSerializer', 'InstitutionMemberSerializer', @@ -19,3 +23,4 @@ __all__ = [ 'InstitutionCreateUpdateSerializer', 'FollowResponseSerializer', ] + diff --git a/apps/profiles/serializers/institution_serializers.py b/apps/profiles/serializers/institution_serializers.py index b0bbcc7..25970d6 100644 --- a/apps/profiles/serializers/institution_serializers.py +++ b/apps/profiles/serializers/institution_serializers.py @@ -1,7 +1,9 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from drf_spectacular.utils import extend_schema_field -from apps.profiles.models.institution import ( +from django.db.models import Q +from apps.profiles.models import ( + Region, Institution, InstitutionMember, InstitutionTimeline, @@ -12,6 +14,33 @@ from apps.profiles.models.institution import ( User = get_user_model() +class RegionMiniSerializer(serializers.ModelSerializer): + class Meta: + model = Region + fields = ['id', 'name', 'slug', 'code'] + read_only_fields = fields + + +class RegionSerializer(serializers.ModelSerializer): + institutions_count = serializers.IntegerField(source='institutions.count', read_only=True) + + class Meta: + model = Region + fields = [ + 'id', + 'name', + 'slug', + 'code', + 'description', + 'is_active', + 'institutions_count', + 'created_at', + 'updated_at', + ] + read_only_fields = ['id', 'slug', 'institutions_count', 'created_at', 'updated_at'] + + + class InstitutionTimelineSerializer(serializers.ModelSerializer): class Meta: model = InstitutionTimeline @@ -99,6 +128,9 @@ def _resolve_institution_media_url(image_field, request, fallback_url=None): class InstitutionListSerializer(serializers.ModelSerializer): type_display = serializers.CharField(source='get_type_display', read_only=True) verification_status_display = serializers.CharField(source='get_verification_status_display', read_only=True) + region = RegionMiniSerializer(read_only=True) + region_id = serializers.IntegerField(source='region.id', read_only=True) + region_name = serializers.CharField(source='region.name', read_only=True) is_following = serializers.SerializerMethodField() avatar = serializers.SerializerMethodField() cover_image = serializers.SerializerMethodField() @@ -111,6 +143,9 @@ class InstitutionListSerializer(serializers.ModelSerializer): 'slug', 'type', 'type_display', + 'region', + 'region_id', + 'region_name', 'country', 'city', 'address', @@ -149,6 +184,9 @@ class InstitutionListSerializer(serializers.ModelSerializer): class InstitutionDetailSerializer(serializers.ModelSerializer): type_display = serializers.CharField(source='get_type_display', read_only=True) verification_status_display = serializers.CharField(source='get_verification_status_display', read_only=True) + region = RegionSerializer(read_only=True) + region_id = serializers.IntegerField(source='region.id', read_only=True) + region_name = serializers.CharField(source='region.name', read_only=True) timeline_entries = InstitutionTimelineSerializer(many=True, read_only=True) media_items = InstitutionMediaSerializer(many=True, read_only=True) projects = serializers.SerializerMethodField() @@ -168,6 +206,9 @@ class InstitutionDetailSerializer(serializers.ModelSerializer): 'slug', 'type', 'type_display', + 'region', + 'region_id', + 'region_name', 'country', 'city', 'address', @@ -202,6 +243,9 @@ class InstitutionDetailSerializer(serializers.ModelSerializer): read_only_fields = [ 'id', 'slug', + 'region', + 'region_id', + 'region_name', 'verification_status', 'verification_status_display', 'follower_count', @@ -349,11 +393,19 @@ class InstitutionDetailSerializer(serializers.ModelSerializer): class InstitutionCreateUpdateSerializer(serializers.ModelSerializer): + region = serializers.PrimaryKeyRelatedField( + queryset=Region.objects.all(), + required=True, + allow_null=False, + help_text="Region ID this institution belongs to (required)." + ) + class Meta: model = Institution fields = [ 'name', 'type', + 'region', 'country', 'city', 'address', @@ -378,6 +430,17 @@ class InstitutionCreateUpdateSerializer(serializers.ModelSerializer): else: data = dict(data) + # Normalize region: handle ID, dict with id, slug, or name + region_val = data.get('region') if data.get('region') is not None else data.get('region_id') + if region_val is not None: + if isinstance(region_val, dict) and 'id' in region_val: + data['region'] = region_val['id'] + elif isinstance(region_val, str) and not str(region_val).isdigit(): + r = Region.objects.filter(Q(slug=region_val) | Q(name__iexact=region_val)).first() + if r: + data['region'] = r.id + + # Normalize institution type if passed in Persian or other aliases inst_type = data.get('type') if inst_type: @@ -434,14 +497,37 @@ class InstitutionCreateUpdateSerializer(serializers.ModelSerializer): return super().to_internal_value(data) + def validate(self, attrs): + request = self.context.get('request') + if request and request.user and request.user.is_authenticated: + user = request.user + if getattr(user, 'is_regional_admin', False) and not (getattr(user, 'is_super_admin', False) or getattr(user, 'is_superuser', False)): + if self.instance and hasattr(user, 'can_manage_region') and not user.can_manage_region(self.instance.region): + raise serializers.ValidationError({ + "detail": "You do not have administrative authority over institutions in this region." + }) + new_region = attrs.get('region') + if new_region and hasattr(user, 'can_manage_region') and not user.can_manage_region(new_region): + raise serializers.ValidationError({ + "region": "You cannot assign an institution to a region outside your administrative jurisdiction." + }) + return attrs + def create(self, validated_data): user = self.context.get('request').user if self.context.get('request') else None if user and user.is_authenticated: validated_data['created_by'] = user - # Only super admins or regional admins can set is_featured directly upon creation - if user and not (getattr(user, 'is_super_admin', False) or getattr(user, 'is_regional_admin', False)): - validated_data['is_featured'] = False + # Only super admins or regional admins managing this region can set is_featured directly upon creation + if user: + if getattr(user, 'is_super_admin', False) or getattr(user, 'is_superuser', False): + pass + elif getattr(user, 'is_regional_admin', False): + region = validated_data.get('region') + if hasattr(user, 'can_manage_region') and not user.can_manage_region(region): + validated_data['is_featured'] = False + else: + validated_data['is_featured'] = False institution = super().create(validated_data) diff --git a/apps/profiles/tests/test_phase2_profiles.py b/apps/profiles/tests/test_phase2_profiles.py index d0d2d5a..959a5ad 100644 --- a/apps/profiles/tests/test_phase2_profiles.py +++ b/apps/profiles/tests/test_phase2_profiles.py @@ -4,7 +4,8 @@ from django.contrib.auth import get_user_model from rest_framework.test import APIClient from rest_framework import status -from apps.profiles.models.institution import ( +from apps.profiles.models import ( + Region, Institution, InstitutionMember, InstitutionTimeline, @@ -37,6 +38,7 @@ class Phase2InstitutionProfileTests(TestCase): password="SecurePassword123!", fullname="Sheikh Hassan Al-Berlini", user_type=User.UserType.INSTITUTION_ADMIN, + is_staff=True, country="Germany", city="Berlin" ) @@ -57,10 +59,19 @@ class Phase2InstitutionProfileTests(TestCase): city="Frankfurt" ) + # Region + self.region = Region.objects.create( + name="Central Europe", + slug="central-europe", + code="CEU", + description="Central European zone" + ) + # Primary Institution self.institution = Institution.objects.create( name="Imam Ali Islamic Center Berlin", type=Institution.InstitutionType.CULTURAL_CENTER, + region=self.region, country="Germany", city="Berlin", address="Flughafenstrasse 42, 12053 Berlin", @@ -105,10 +116,20 @@ class Phase2InstitutionProfileTests(TestCase): "established_year": 2005, "tags": ["Library", "Language Courses"] } + + # 1. Omitting region must fail validation (every institution must have a region) + fail_response = self.client.post("/api/v1/profiles/", payload, format="json") + self.assertEqual(fail_response.status_code, status.HTTP_400_BAD_REQUEST) + error_fields = [e.get('field') for e in fail_response.data.get('errors', [])] if 'errors' in fail_response.data else list(fail_response.data.keys()) + self.assertIn("region", error_fields) + + # 2. Including region creates institution successfully + payload["region"] = self.region.id response = self.client.post("/api/v1/profiles/", payload, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data["name"], "Al-Zahra Cultural Center Vienna") self.assertTrue(response.data["slug"]) + self.assertEqual(response.data["region"]["id"], self.region.id) self.assertEqual(response.data["verification_status"], "pending") # Verify Creator is registered as Admin member @@ -284,3 +305,104 @@ class Phase2InstitutionProfileTests(TestCase): del_member_resp = self.client.delete(f"/api/v1/profiles/{self.institution.id}/members/{member_id}/") self.assertEqual(del_member_resp.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(InstitutionMember.objects.filter(id=member_id).count(), 0) + + def test_region_crud_and_management(self): + # 1. Anonymous can list active regions (for dropdowns / create form) + resp = self.client.get("/api/v1/profiles/regions/") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertGreaterEqual(len(resp.data), 1) + + # 2. Staff user can create a new region + self.client.force_authenticate(user=self.admin_user) + new_region_data = { + "name": "Scandinavia & Baltics", + "code": "SCA", + "description": "Northern European countries" + } + create_resp = self.client.post("/api/v1/profiles/regions/", new_region_data, format="json") + self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED) + new_region_id = create_resp.data["id"] + self.assertEqual(create_resp.data["slug"], "scandinavia-baltics") + + # 3. Regular user cannot create region + self.client.force_authenticate(user=self.regular_user) + forbid_resp = self.client.post("/api/v1/profiles/regions/", {"name": "Invalid Region"}, format="json") + self.assertEqual(forbid_resp.status_code, status.HTTP_403_FORBIDDEN) + + # 4. Deleting a region with institutions assigned must fail + self.client.force_authenticate(user=self.admin_user) + del_fail = self.client.delete(f"/api/v1/profiles/regions/{self.region.id}/") + self.assertEqual(del_fail.status_code, status.HTTP_400_BAD_REQUEST) + self.assertTrue(Region.objects.filter(id=self.region.id).exists()) + + # 5. Deleting a region without institutions succeeds + del_ok = self.client.delete(f"/api/v1/profiles/regions/{new_region_id}/") + self.assertEqual(del_ok.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Region.objects.filter(id=new_region_id).exists()) + + def test_institution_region_filtering(self): + # Create second region and institution + mena_region = Region.objects.create(name="Middle East", slug="middle-east", code="MEA") + Institution.objects.create( + name="Tehran Grand Center", + type=Institution.InstitutionType.MOSQUE, + region=mena_region, + country="Iran", + city="Tehran", + created_by=self.admin_user + ) + + # 1. Filter by region ID + resp_id = self.client.get(f"/api/v1/profiles/?region={mena_region.id}") + self.assertEqual(resp_id.status_code, status.HTTP_200_OK) + self.assertEqual(len(resp_id.data["results"]), 1) + self.assertEqual(resp_id.data["results"][0]["name"], "Tehran Grand Center") + + # 2. Filter by region slug + resp_slug = self.client.get("/api/v1/profiles/?region=central-europe") + self.assertEqual(resp_slug.status_code, status.HTTP_200_OK) + self.assertEqual(len(resp_slug.data["results"]), 1) + self.assertEqual(resp_slug.data["results"][0]["name"], "Imam Ali Islamic Center Berlin") + + def test_institution_region_edit_form(self): + self.client.force_authenticate(user=self.admin_user) + new_region = Region.objects.create(name="Western Europe", slug="western-europe", code="WEU") + + # 1. Update institution region via PATCH + patch_resp = self.client.patch( + f"/api/v1/profiles/{self.institution.id}/", + {"region": new_region.id}, + format="json" + ) + self.assertEqual(patch_resp.status_code, status.HTTP_200_OK) + self.assertEqual(patch_resp.data["region"]["id"], new_region.id) + self.assertEqual(patch_resp.data["region"]["name"], "Western Europe") + + # Verify DB updated + self.institution.refresh_from_db() + self.assertEqual(self.institution.region_id, new_region.id) + + def test_region_admin_panel_configuration(self): + from utils.admin import project_admin_site + from apps.profiles.admin import RegionAdmin, InstitutionAdmin + + # Verify Region and Institution are registered in the project admin site + self.assertIn(Region, project_admin_site._registry) + self.assertIn(Institution, project_admin_site._registry) + + reg_admin = project_admin_site._registry[Region] + inst_admin = project_admin_site._registry[Institution] + + self.assertIsInstance(reg_admin, RegionAdmin) + self.assertIsInstance(inst_admin, InstitutionAdmin) + + # Verify region field in InstitutionAdmin + self.assertIn('region', inst_admin.list_filter) + self.assertIn('region_display', inst_admin.list_display) + + # Verify region in fieldsets + fieldset_fields = [] + for _, config in inst_admin.fieldsets: + fieldset_fields.extend(config.get('fields', ())) + self.assertIn('region', fieldset_fields) + diff --git a/apps/profiles/tests/test_regional_admin.py b/apps/profiles/tests/test_regional_admin.py new file mode 100644 index 0000000..ba5295a --- /dev/null +++ b/apps/profiles/tests/test_regional_admin.py @@ -0,0 +1,256 @@ +from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status + +from apps.profiles.models import Region, Institution, InstitutionMember +from apps.profiles.admin import InstitutionAdmin, RegionAdmin +from apps.projects.models import Project +from apps.account.serializers.auth_serializers import UserMeSerializer +from utils.admin import project_admin_site + +User = get_user_model() + + +class RegionalAdminPermissionTests(TestCase): + """ + Comprehensive test suite verifying regional admin scoping and permissions: + - User model can_manage_region and has_regional_perm methods. + - Regional admin CRUD isolation for institutions (permitted within managed region, 403 outside). + - API managed_only filtering honoring admin jurisdiction. + - Project editing scoped to institution regions. + - Django Admin get_queryset and formfield_for_foreignkey scoping. + - UserMeSerializer exposure of managed_regions. + """ + + def setUp(self): + self.client = APIClient() + self.rf = RequestFactory() + + # Retrieve or create regions (Europe & Asia are pre-seeded by migration 0002) + self.region_europe, _ = Region.objects.get_or_create( + slug="europe", + defaults={"name": "Europe", "code": "EU", "description": "European Region"} + ) + self.region_asia, _ = Region.objects.get_or_create( + slug="asia", + defaults={"name": "Asia", "code": "AS", "description": "Asian Region"} + ) + + # Create Super Admin + self.super_admin = User.objects.create_superuser( + email="super@domain.com", + password="SecurePassword123!", + fullname="Global Super Admin" + ) + + # Create Regional Admin assigned to Europe only + self.eu_admin = User.objects.create_user( + email="eu.admin@domain.com", + password="SecurePassword123!", + fullname="Europe Regional Admin", + user_type=User.UserType.REGIONAL_ADMIN, + is_staff=True, + regional_permissions={ + "institutions": ["view", "change", "delete"], + "projects": ["view", "change"] + } + ) + self.eu_admin.managed_regions.add(self.region_europe) + + # Create Regular User + self.regular_user = User.objects.create_user( + email="regular@domain.com", + password="SecurePassword123!", + fullname="Regular User", + user_type=User.UserType.CLIENT + ) + + # Create Institutions in Europe and Asia + self.inst_europe = Institution.objects.create( + name="Berlin Islamic Center", + slug="berlin-islamic-center", + type="mosque", + region=self.region_europe, + country="Germany", + city="Berlin", + address="Flughafenstr 43", + created_by=self.eu_admin + ) + self.inst_asia = Institution.objects.create( + name="Tokyo Camii Center", + slug="tokyo-camii-center", + type="mosque", + region=self.region_asia, + country="Japan", + city="Tokyo", + address="1-19 Oyama-cho", + created_by=self.super_admin + ) + + # Create Projects + self.proj_europe = Project.objects.create( + title="European Youth Conference", + owner_institution=self.inst_europe, + created_by=self.eu_admin, + status="active" + ) + self.proj_asia = Project.objects.create( + title="Tokyo Intercultural Expo", + owner_institution=self.inst_asia, + created_by=self.super_admin, + status="active" + ) + + # ------------------------------------------------------------------------- + # 1. Model-level jurisdiction methods + # ------------------------------------------------------------------------- + def test_user_can_manage_region(self): + # Super admin can manage all regions + self.assertTrue(self.super_admin.can_manage_region(self.region_europe)) + self.assertTrue(self.super_admin.can_manage_region(self.region_asia)) + + # Europe admin can manage Europe, but NOT Asia + self.assertTrue(self.eu_admin.can_manage_region(self.region_europe)) + self.assertFalse(self.eu_admin.can_manage_region(self.region_asia)) + + # Regular user cannot manage any region + self.assertFalse(self.regular_user.can_manage_region(self.region_europe)) + self.assertFalse(self.regular_user.can_manage_region(self.region_asia)) + + def test_user_has_regional_perm(self): + # EU admin has change permission on Europe + self.assertTrue(self.eu_admin.has_regional_perm("institutions.change", self.region_europe)) + # EU admin does NOT have permission on Asia + self.assertFalse(self.eu_admin.has_regional_perm("institutions.change", self.region_asia)) + # Super admin has all permissions on any region + self.assertTrue(self.super_admin.has_regional_perm("anything", self.region_asia)) + + # ------------------------------------------------------------------------- + # 2. Institution permission checks + # ------------------------------------------------------------------------- + def test_institution_is_admin_regional_scoping(self): + # EU admin is admin of European institution, NOT Asian institution + self.assertTrue(self.inst_europe.is_admin(self.eu_admin)) + self.assertFalse(self.inst_asia.is_admin(self.eu_admin)) + + # Super admin is admin of both + self.assertTrue(self.inst_europe.is_admin(self.super_admin)) + self.assertTrue(self.inst_asia.is_admin(self.super_admin)) + + def test_institution_edit_api_regional_scoping(self): + # Authenticate as EU admin + self.client.force_authenticate(user=self.eu_admin) + + # 1. Edit European institution -> Success (200) + res_eu = self.client.patch( + f"/api/v1/profiles/{self.inst_europe.id}/", + {"description": "Updated by EU admin"}, + format="json" + ) + self.assertEqual(res_eu.status_code, status.HTTP_200_OK) + self.inst_europe.refresh_from_db() + self.assertEqual(self.inst_europe.description, "Updated by EU admin") + + # 2. Edit Asian institution -> Forbidden (403) + res_asia = self.client.patch( + f"/api/v1/profiles/{self.inst_asia.id}/", + {"description": "Attempted update outside jurisdiction"}, + format="json" + ) + self.assertEqual(res_asia.status_code, status.HTTP_403_FORBIDDEN) + + def test_institution_list_managed_only_filter(self): + self.client.force_authenticate(user=self.eu_admin) + + # Query all institutions + res_all = self.client.get("/api/v1/profiles/") + self.assertEqual(res_all.status_code, status.HTTP_200_OK) + all_ids = [item["id"] for item in res_all.data["results"]] + self.assertIn(self.inst_europe.id, all_ids) + self.assertIn(self.inst_asia.id, all_ids) + + # Query with managed_only=true + res_managed = self.client.get("/api/v1/profiles/?managed_only=true") + self.assertEqual(res_managed.status_code, status.HTTP_200_OK) + managed_ids = [item["id"] for item in res_managed.data["results"]] + self.assertIn(self.inst_europe.id, managed_ids) + self.assertNotIn(self.inst_asia.id, managed_ids) + + # ------------------------------------------------------------------------- + # 3. Project Scoping + # ------------------------------------------------------------------------- + def test_project_can_user_edit_regional_scoping(self): + # EU admin can edit European project, but NOT Asian project + self.assertTrue(self.proj_europe.can_user_edit(self.eu_admin)) + self.assertFalse(self.proj_asia.can_user_edit(self.eu_admin)) + + # Super admin can edit both + self.assertTrue(self.proj_europe.can_user_edit(self.super_admin)) + self.assertTrue(self.proj_asia.can_user_edit(self.super_admin)) + + def test_project_edit_api_regional_scoping(self): + self.client.force_authenticate(user=self.eu_admin) + + # 1. Edit European project -> Success (200) + res_eu = self.client.patch( + f"/api/v1/projects/{self.proj_europe.id}/", + {"description": "Updated conference agenda"}, + format="json" + ) + self.assertEqual(res_eu.status_code, status.HTTP_200_OK) + + # 2. Edit Asian project -> Forbidden (403) + res_asia = self.client.patch( + f"/api/v1/projects/{self.proj_asia.id}/", + {"description": "Unauthorized edit"}, + format="json" + ) + self.assertEqual(res_asia.status_code, status.HTTP_403_FORBIDDEN) + + # ------------------------------------------------------------------------- + # 4. Django Admin Isolation + # ------------------------------------------------------------------------- + def test_admin_panel_institution_scoping(self): + admin_obj = InstitutionAdmin(Institution, project_admin_site) + + # Request by EU admin + req_eu = self.rf.get("/admin/profiles/institution/") + req_eu.user = self.eu_admin + + qs_eu = admin_obj.get_queryset(req_eu) + self.assertIn(self.inst_europe, qs_eu) + self.assertNotIn(self.inst_asia, qs_eu) + + # Request by Super Admin + req_super = self.rf.get("/admin/profiles/institution/") + req_super.user = self.super_admin + + qs_super = admin_obj.get_queryset(req_super) + self.assertIn(self.inst_europe, qs_super) + self.assertIn(self.inst_asia, qs_super) + + def test_admin_panel_foreign_key_formfield_scoping(self): + admin_obj = InstitutionAdmin(Institution, project_admin_site) + + req_eu = self.rf.get("/admin/profiles/institution/add/") + req_eu.user = self.eu_admin + + db_field = Institution._meta.get_field("region") + form_field = admin_obj.formfield_for_foreignkey(db_field, req_eu) + + # Dropdown queryset for regional admin should only contain Europe + region_ids = list(form_field.queryset.values_list("id", flat=True)) + self.assertIn(self.region_europe.id, region_ids) + self.assertNotIn(self.region_asia.id, region_ids) + + # ------------------------------------------------------------------------- + # 5. UserMeSerializer exposure + # ------------------------------------------------------------------------- + def test_user_me_serializer_exposes_managed_regions(self): + serializer = UserMeSerializer(self.eu_admin) + data = serializer.data + self.assertIn("managed_regions", data) + self.assertEqual(len(data["managed_regions"]), 1) + self.assertEqual(data["managed_regions"][0]["code"], "EU") + self.assertEqual(data["managed_regions"][0]["name"], "Europe") diff --git a/apps/profiles/urls.py b/apps/profiles/urls.py index f2123c8..6b901f7 100644 --- a/apps/profiles/urls.py +++ b/apps/profiles/urls.py @@ -1,5 +1,7 @@ from django.urls import path from apps.profiles.views import ( + RegionListCreateView, + RegionDetailView, InstitutionListCreateView, InstitutionDetailView, InstitutionTimelineView, @@ -12,6 +14,10 @@ from apps.profiles.views import ( ) urlpatterns = [ + # Geographical Regions CRUD & Dropdown + path('regions/', RegionListCreateView.as_view(), name='region_list_create'), + path('regions//', RegionDetailView.as_view(), name='region_detail'), + # Organization Profiles CRUD & Search path('', InstitutionListCreateView.as_view(), name='institution_list_create'), path('/', InstitutionDetailView.as_view(), name='institution_detail'), diff --git a/apps/profiles/views/__init__.py b/apps/profiles/views/__init__.py index 97ecbb7..a522619 100644 --- a/apps/profiles/views/__init__.py +++ b/apps/profiles/views/__init__.py @@ -1,4 +1,6 @@ from .institution_views import ( + RegionListCreateView, + RegionDetailView, InstitutionListCreateView, InstitutionDetailView, InstitutionTimelineView, @@ -11,6 +13,8 @@ from .institution_views import ( ) __all__ = [ + 'RegionListCreateView', + 'RegionDetailView', 'InstitutionListCreateView', 'InstitutionDetailView', 'InstitutionTimelineView', @@ -21,3 +25,4 @@ __all__ = [ 'InstitutionMembersView', 'InstitutionMemberDetailView', ] + diff --git a/apps/profiles/views/institution_views.py b/apps/profiles/views/institution_views.py index 8331aa2..ca18f6e 100644 --- a/apps/profiles/views/institution_views.py +++ b/apps/profiles/views/institution_views.py @@ -10,7 +10,8 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse, inline_serializer -from apps.profiles.models.institution import ( +from apps.profiles.models import ( + Region, Institution, InstitutionMember, InstitutionTimeline, @@ -23,6 +24,8 @@ from apps.profiles.permissions import ( CanManageInstitutionMembers, ) from apps.profiles.serializers import ( + RegionMiniSerializer, + RegionSerializer, InstitutionListSerializer, InstitutionDetailSerializer, InstitutionCreateUpdateSerializer, @@ -38,6 +41,111 @@ logger = logging.getLogger(__name__) User = get_user_model() +class RegionListCreateView(GenericAPIView): + serializer_class = RegionSerializer + + def get_permissions(self): + if self.request.method == 'POST': + return [IsAuthenticated()] + return [AllowAny()] + + def get_queryset(self): + if getattr(self, 'swagger_fake_view', False): + return Region.objects.none() + qs = Region.objects.all() + if not (self.request.user and self.request.user.is_authenticated and self.request.user.is_staff): + qs = qs.filter(is_active=True) + search = self.request.query_params.get('search') + if search: + qs = qs.filter(Q(name__icontains=search) | Q(code__icontains=search) | Q(description__icontains=search)) + return qs.order_by('name') + + @extend_schema( + summary="List all regions", + description="Returns list of available regions for dropdowns and filtering.", + parameters=[ + OpenApiParameter('search', str, description='Search query across region name, code, description'), + ], + responses={200: RegionSerializer(many=True)}, + tags=["Institutions & Profiles"], + ) + def get(self, request, *args, **kwargs): + qs = self.get_queryset() + serializer = self.get_serializer(qs, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Create a new region", + description="Creates a new region (Admin or Staff required).", + request=RegionSerializer, + responses={201: RegionSerializer}, + tags=["Institutions & Profiles"], + ) + def post(self, request, *args, **kwargs): + if not (request.user.is_staff or getattr(request.user, 'is_super_admin', False)): + return Response({'error': _("Staff or admin privileges required to create regions.")}, status=status.HTTP_403_FORBIDDEN) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + region = serializer.save() + return Response(self.get_serializer(region).data, status=status.HTTP_201_CREATED) + + +class RegionDetailView(GenericAPIView): + serializer_class = RegionSerializer + + def get_permissions(self): + if self.request.method in ['PUT', 'PATCH', 'DELETE']: + return [IsAuthenticated()] + return [AllowAny()] + + def get_object(self): + lookup = self.kwargs.get('pk_or_slug') + if str(lookup).isdigit(): + return get_object_or_404(Region, id=int(lookup)) + return get_object_or_404(Region, slug=lookup) + + @extend_schema( + summary="Get region details", + description="Retrieves a region by ID or slug.", + responses={200: RegionSerializer}, + tags=["Institutions & Profiles"], + ) + def get(self, request, pk_or_slug, *args, **kwargs): + region = self.get_object() + return Response(self.get_serializer(region).data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Update region", + description="Updates region details (Admin/Staff only).", + request=RegionSerializer, + responses={200: RegionSerializer}, + tags=["Institutions & Profiles"], + ) + def patch(self, request, pk_or_slug, *args, **kwargs): + if not (request.user.is_staff or getattr(request.user, 'is_super_admin', False)): + return Response({'error': _("Staff or admin privileges required to update regions.")}, status=status.HTTP_403_FORBIDDEN) + region = self.get_object() + serializer = self.get_serializer(region, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Delete region", + description="Deletes a region (Admin/Staff only). Cannot delete if institutions are linked.", + responses={204: OpenApiResponse(description="Region deleted")}, + tags=["Institutions & Profiles"], + ) + def delete(self, request, pk_or_slug, *args, **kwargs): + if not (request.user.is_staff or getattr(request.user, 'is_super_admin', False)): + return Response({'error': _("Staff or admin privileges required to delete regions.")}, status=status.HTTP_403_FORBIDDEN) + region = self.get_object() + if region.institutions.exists(): + return Response({'error': _("Cannot delete region that has institutions assigned to it.")}, status=status.HTTP_400_BAD_REQUEST) + region.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def get_institution_by_id_or_slug(lookup_val): if str(lookup_val).isdigit(): return get_object_or_404(Institution, id=int(lookup_val), is_active=True) @@ -57,7 +165,7 @@ class InstitutionListCreateView(GenericAPIView): if getattr(self, 'swagger_fake_view', False): return Institution.objects.none() - qs = Institution.objects.filter(is_active=True) + qs = Institution.objects.filter(is_active=True).select_related('region') # Filters search = self.request.query_params.get('search') @@ -66,9 +174,17 @@ class InstitutionListCreateView(GenericAPIView): Q(name__icontains=search) | Q(city__icontains=search) | Q(country__icontains=search) | + Q(region__name__icontains=search) | Q(description__icontains=search) ) + region_param = self.request.query_params.get('region') + if region_param: + if region_param.isdigit(): + qs = qs.filter(region_id=int(region_param)) + else: + qs = qs.filter(Q(region__slug=region_param) | Q(region__name__iexact=region_param)) + country = self.request.query_params.get('country') if country: qs = qs.filter(country__iexact=country) @@ -93,13 +209,25 @@ class InstitutionListCreateView(GenericAPIView): if verification: qs = qs.filter(verification_status=verification) + managed_only = self.request.query_params.get('managed_only') + if managed_only and managed_only.lower() in ['true', '1']: + if self.request.user and self.request.user.is_authenticated: + user = self.request.user + if not (getattr(user, 'is_super_admin', False) or getattr(user, 'is_superuser', False)): + if hasattr(user, 'get_managed_regions_queryset'): + qs = qs.filter(region__in=user.get_managed_regions_queryset()) + else: + qs = qs.none() + return qs.order_by('-is_featured', '-created_at') @extend_schema( summary="List active institutions", description="Returns a paginated list of mosques, cultural centers, and institutes with filtering.", parameters=[ - OpenApiParameter('search', str, description='Search query across name, city, country, description'), + OpenApiParameter('search', str, description='Search query across name, city, country, region, description'), + OpenApiParameter('region', str, description='Filter by region ID, slug, or name'), + OpenApiParameter('managed_only', bool, description="Filter to return only institutions within caller's managed regions"), OpenApiParameter('country', str, description='Filter by country name'), OpenApiParameter('city', str, description='Filter by city name'), OpenApiParameter('type', str, description='Filter by institution type (mosque, cultural_center, etc.)'), diff --git a/apps/projects/models/project.py b/apps/projects/models/project.py index a28633f..8061717 100644 --- a/apps/projects/models/project.py +++ b/apps/projects/models/project.py @@ -151,10 +151,18 @@ class Project(models.Model): def can_user_edit(self, user): if not user or not user.is_authenticated: return False - if getattr(user, 'is_super_admin', False) or getattr(user, 'is_regional_admin', False): + if getattr(user, 'is_super_admin', False): return True + if getattr(user, 'is_regional_admin', False): + if hasattr(user, 'can_manage_region'): + if self.owner_institution and user.can_manage_region(self.owner_institution.region): + return True + for collab in self.collaborating_institutions.all(): + if user.can_manage_region(collab.region): + return True + return False # Check owner institution - if self.owner_institution.is_editor(user): + if self.owner_institution and self.owner_institution.is_editor(user): return True # Check collaborating institutions for collab in self.collaborating_institutions.all(): @@ -163,6 +171,7 @@ class Project(models.Model): return False + class KanbanColumn(models.Model): project = models.ForeignKey( Project, diff --git a/apps/projects/permissions.py b/apps/projects/permissions.py index 1024a53..c674ed6 100644 --- a/apps/projects/permissions.py +++ b/apps/projects/permissions.py @@ -22,7 +22,7 @@ class IsProjectParticipantOrReadOnly(BasePermission): if not request.user or not request.user.is_authenticated: return False - if getattr(request.user, 'is_super_admin', False) or getattr(request.user, 'is_regional_admin', False): + if getattr(request.user, 'is_super_admin', False): return True # Resolve the parent Project object diff --git a/apps/projects/serializers/project_serializers.py b/apps/projects/serializers/project_serializers.py index 9db0010..3c9d2cd 100644 --- a/apps/projects/serializers/project_serializers.py +++ b/apps/projects/serializers/project_serializers.py @@ -242,6 +242,41 @@ class ProjectCreateUpdateSerializer(serializers.ModelSerializer): 'actual_end_date', ] + def validate(self, attrs): + request = self.context.get('request') + if request and request.user and request.user.is_authenticated: + user = request.user + if getattr(user, 'is_super_admin', False) or getattr(user, 'is_superuser', False): + return attrs + + if not self.instance: + # Project creation: user must be permitted to create on behalf of owner institution + owner = attrs.get('owner_institution') + if owner: + if getattr(user, 'is_regional_admin', False): + if hasattr(user, 'can_manage_region') and not user.can_manage_region(owner.region): + raise serializers.ValidationError({ + "owner_institution": "You do not have administrative authority over institutions in this region." + }) + elif not owner.is_editor(user): + raise serializers.ValidationError({ + "owner_institution": "You are not an authorized editor or administrator of this institution." + }) + else: + # Project update: only validate owner if it is explicitly being modified + if 'owner_institution' in attrs: + new_owner = attrs['owner_institution'] + if getattr(user, 'is_regional_admin', False): + if hasattr(user, 'can_manage_region') and not user.can_manage_region(new_owner.region): + raise serializers.ValidationError({ + "owner_institution": "You do not have administrative authority over institutions in this region." + }) + elif not new_owner.is_editor(user): + raise serializers.ValidationError({ + "owner_institution": "You are not an authorized editor or administrator of this institution." + }) + return attrs + def create(self, validated_data): request = self.context.get('request') collaborating = validated_data.pop('collaborating_institutions', []) diff --git a/config/settings/base.py b/config/settings/base.py index bac46e9..9a7a1d8 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -447,6 +447,11 @@ UNFOLD = { "icon": "account_balance", "link": lambda request: admin_url_generator(request, "profiles_institution_changelist"), }, + { + "title": _("Regions"), + "icon": "public", + "link": lambda request: admin_url_generator(request, "profiles_region_changelist"), + }, { "title": _("Institution Members"), "icon": "badge", diff --git a/dump.rdb b/dump.rdb index 0767739c0dae1e19e91afbe3400d0268e8591072..4f0840dfff45d8f56a02438d8dabae3c6430019e 100644 GIT binary patch delta 45 zcma!yoM5OCYkoZI7e{GvYKm@dYVIMHcU%m=I1=;IbdwT`Qx7ow|FUt`eAWQ|H~^ud B5}g15 delta 45 zcma!yoM5OiiThC2FOJgU)D+#^)Z9Zge%uVdI1=;IbdwT`Qx7owuX(xR;*y#m0RW+u B6FL9@ diff --git a/schema.yml b/schema.yml index 3df5795..51bf956 100644 --- a/schema.yml +++ b/schema.yml @@ -4091,8 +4091,8 @@ paths: description: '' patch: operationId: v1_meetings_partial_update - description: Accept, reschedule, decline, complete, or update meeting room URL - and agenda notes. + description: Accept, reschedule, decline (requires rejection_reason or admin_notes + explanation), complete, or update meeting room URL and agenda notes. summary: Update or respond to meeting request parameters: - in: path @@ -4167,6 +4167,11 @@ paths: schema: type: boolean description: Filter by featured status + - in: query + name: managed_only + schema: + type: boolean + description: Filter to return only institutions within caller's managed regions - name: page required: false in: query @@ -4179,11 +4184,16 @@ paths: description: Number of results to return per page. schema: type: integer + - in: query + name: region + schema: + type: string + description: Filter by region ID, slug, or name - in: query name: search schema: type: string - description: Search query across name, city, country, description + description: Search query across name, city, country, region, description - in: query name: tag schema: @@ -4706,6 +4716,156 @@ paths: responses: '204': description: Entry deleted + /api/v1/profiles/regions/: + get: + operationId: v1_profiles_regions_list + description: Returns list of available regions for dropdowns and filtering. + summary: List all regions + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: search + schema: + type: string + description: Search query across region name, code, description + tags: + - Institutions & Profiles + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedRegionList' + description: '' + post: + operationId: v1_profiles_regions_create + description: Creates a new region (Admin or Staff required). + summary: Create a new region + tags: + - Institutions & Profiles + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RegionRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/RegionRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/RegionRequest' + required: true + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/Region' + description: '' + /api/v1/profiles/regions/{pk_or_slug}/: + get: + operationId: v1_profiles_regions_retrieve + description: Retrieves a region by ID or slug. + summary: Get region details + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true + tags: + - Institutions & Profiles + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Region' + description: '' + patch: + operationId: v1_profiles_regions_partial_update + description: Updates region details (Admin/Staff only). + summary: Update region + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true + tags: + - Institutions & Profiles + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedRegionRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedRegionRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedRegionRequest' + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Region' + description: '' + delete: + operationId: v1_profiles_regions_destroy + description: Deletes a region (Admin/Staff only). Cannot delete if institutions + are linked. + summary: Delete region + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true + tags: + - Institutions & Profiles + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '204': + description: Region deleted /api/v1/projects/: get: operationId: v1_projects_list @@ -8042,6 +8202,9 @@ components: allOf: - $ref: '#/components/schemas/InstitutionTypeEnum' title: Institution Type + region: + type: integer + description: Region ID this institution belongs to (required). country: type: string minLength: 1 @@ -8115,6 +8278,7 @@ components: - city - country - name + - region InstitutionDetail: type: object properties: @@ -8139,6 +8303,16 @@ components: type_display: type: string readOnly: true + region: + allOf: + - $ref: '#/components/schemas/Region' + readOnly: true + region_id: + type: integer + readOnly: true + region_name: + type: string + readOnly: true country: type: string maxLength: 255 @@ -8189,13 +8363,12 @@ components: title: Established Year (CE) cover_image: type: string - format: uri nullable: true + readOnly: true avatar: type: string - format: uri nullable: true - title: Logo / Avatar + readOnly: true social_media: title: Social Media Links description: 'Dictionary with keys: facebook, twitter, instagram, linkedin, @@ -8246,6 +8419,14 @@ components: items: $ref: '#/components/schemas/InstitutionMedia' readOnly: true + projects: + type: array + items: {} + readOnly: true + posts: + type: array + items: {} + readOnly: true created_at: type: string format: date-time @@ -8255,8 +8436,10 @@ components: format: date-time readOnly: true required: + - avatar - city - country + - cover_image - created_at - created_by_email - follower_count @@ -8267,6 +8450,11 @@ components: - media_items - members_count - name + - posts + - projects + - region + - region_id + - region_name - slug - timeline_entries - type_display @@ -8299,6 +8487,16 @@ components: type_display: type: string readOnly: true + region: + allOf: + - $ref: '#/components/schemas/RegionMini' + readOnly: true + region_id: + type: integer + readOnly: true + region_name: + type: string + readOnly: true country: type: string readOnly: true @@ -8322,13 +8520,10 @@ components: nullable: true avatar: type: string - format: uri nullable: true readOnly: true - title: Logo / Avatar cover_image: type: string - format: uri nullable: true readOnly: true verification_status: @@ -8369,6 +8564,9 @@ components: - latitude - longitude - name + - region + - region_id + - region_name - slug - tags - type @@ -9315,6 +9513,9 @@ components: admin_notes: type: string title: Internal / Admin Notes + rejection_reason: + type: string + title: Rejection / Decline Reason created_at: type: string format: date-time @@ -9376,6 +9577,9 @@ components: maxLength: 500 - type: string maxLength: 0 + rejection_reason: + type: string + title: Rejection / Decline Reason created_at: type: string format: date-time @@ -9433,6 +9637,9 @@ components: admin_notes: type: string title: Internal / Admin Notes + rejection_reason: + type: string + description: Required explanation when rejecting/declining a meeting request. required: - meeting_date - meeting_time @@ -9466,6 +9673,9 @@ components: admin_notes: type: string title: Internal / Admin Notes + rejection_reason: + type: string + description: Required explanation when rejecting/declining a meeting request. required: - meeting_date - meeting_time @@ -10276,6 +10486,29 @@ components: type: array items: $ref: '#/components/schemas/ProjectList' + PaginatedRegionList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Region' PaginatedRegionalDensityList: type: object required: @@ -10629,6 +10862,9 @@ components: allOf: - $ref: '#/components/schemas/InstitutionTypeEnum' title: Institution Type + region: + type: integer + description: Region ID this institution belongs to (required). country: type: string minLength: 1 @@ -10758,6 +10994,9 @@ components: admin_notes: type: string title: Internal / Admin Notes + rejection_reason: + type: string + description: Required explanation when rejecting/declining a meeting request. PatchedNotificationTemplateRequest: type: object properties: @@ -10896,6 +11135,27 @@ components: format: date nullable: true title: Actual Completion Date + PatchedRegionRequest: + type: object + properties: + name: + type: string + minLength: 1 + title: Region Name + description: Name of the geographical or administrative region (e.g. Middle + East, Europe). + maxLength: 255 + code: + type: string + nullable: true + title: Region Code + description: Short code such as MENA, EU, NA, etc. + maxLength: 50 + description: + type: string + nullable: true + is_active: + type: boolean PatchedRepresentativeReportReviewRequest: type: object properties: @@ -12141,6 +12401,53 @@ components: $ref: '#/components/schemas/SingleQuizAnswerRequest' required: - answers + Region: + type: object + properties: + id: + type: integer + readOnly: true + name: + type: string + title: Region Name + description: Name of the geographical or administrative region (e.g. Middle + East, Europe). + maxLength: 255 + slug: + type: string + readOnly: true + nullable: true + title: Slug / URL Identifier + pattern: ^[-\w]+$ + code: + type: string + nullable: true + title: Region Code + description: Short code such as MENA, EU, NA, etc. + maxLength: 50 + description: + type: string + nullable: true + is_active: + type: boolean + institutions_count: + type: integer + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - id + - institutions_count + - name + - slug + - updated_at RegionInfo: type: object properties: @@ -12156,6 +12463,58 @@ components: - browser - ip - user_agent + RegionMini: + type: object + properties: + id: + type: integer + readOnly: true + name: + type: string + readOnly: true + title: Region Name + description: Name of the geographical or administrative region (e.g. Middle + East, Europe). + slug: + type: string + readOnly: true + nullable: true + title: Slug / URL Identifier + pattern: ^[-\w]+$ + code: + type: string + readOnly: true + nullable: true + title: Region Code + description: Short code such as MENA, EU, NA, etc. + required: + - code + - id + - name + - slug + RegionRequest: + type: object + properties: + name: + type: string + minLength: 1 + title: Region Name + description: Name of the geographical or administrative region (e.g. Middle + East, Europe). + maxLength: 255 + code: + type: string + nullable: true + title: Region Code + description: Short code such as MENA, EU, NA, etc. + maxLength: 50 + description: + type: string + nullable: true + is_active: + type: boolean + required: + - name RegionalDensity: type: object properties: @@ -13225,6 +13584,15 @@ components: type: array items: {} readOnly: true + managed_regions: + type: array + items: {} + readOnly: true + regional_permissions: + title: Regional Permission Overrides + description: List of permission names that are restricted to the user's + managed regions (e.g. ['can_manage_institutions', 'can_manage_tickets']). + readOnly: true verification_status: type: string readOnly: true @@ -13243,12 +13611,14 @@ components: - is_staff - is_verified - last_name + - managed_regions - name - permissions - phone - position - preferred_language - rank + - regional_permissions - roles - title - user_type