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'''
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t["base_role_label"]}
+
+
+
+
+ badge
+ {escape(current_role_display)}
+
+
+
+
+
+
+
+
+
+
+ {t["additional_perms_label"]}
+
+
+
+
+
+
+
+
+
{t["institution_label"]}
+
+ apartment
+ {escape(current_inst_display)}
+
+
+ {t["institution_hint"]}
+
+
+
+
+
+
{t["regions_label"]}
+
+ {t["loading"]}
+
+
+ {t["regions_hint"]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t["select_role_sub"]}
+
+
+
+
+
+
+
+
+
+
+
+
+ verified
+ {t["default_perms_label"]}
+
+
0 {t["active_perms_count"]}
+
+
+
+
+
+ {t["role_hint"]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tune
+ {t["matrix_title"]}
+
+
+ {t["matrix_sub"]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t["add_perm_panel_title"]}
+
+
{t["add_perm_panel_sub"]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{t["inst_title"]}
+
{t["inst_sub"]}
+
+
+
+
+
+
+
+
+
+
{t["inst_guidelines_title"]}
+
+ - {t["inst_guideline_1"]}
+ - {t["inst_guideline_2"]}
+
+
+
+
+
+
+
+
+
+
+
+ public
+ {t["regions_title"]}
+
+
+ {t["regions_sub"]}
+
+
+
0 {t["regions_selected_count"]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ '''
+ ]
+
+ 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 0767739..4f0840d 100644
Binary files a/dump.rdb and b/dump.rdb differ
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