Browse Source

feat: implement authentication serializers and related account and meeting management modules

main
sina_sajjadi 15 hours ago
parent
commit
f8c4620e44
  1. 2090
      apps/account/admin/access_modal_widget.py
  2. 43
      apps/account/admin/user.py
  3. 82
      apps/account/admin/widgets.py
  4. 33
      apps/account/migrations/0006_user_managed_regions_user_regional_permissions.py
  5. 88
      apps/account/models/user.py
  6. 19
      apps/account/serializers/auth_serializers.py
  7. 11
      apps/geo_map/views.py
  8. 2
      apps/meetings/admin.py
  9. 18
      apps/meetings/migrations/0002_meetingrequest_rejection_reason.py
  10. 1
      apps/meetings/models/meeting.py
  11. 26
      apps/meetings/serializers/meeting_serializers.py
  12. 34
      apps/meetings/tests/test_phase7_meetings.py
  13. 2
      apps/meetings/views/meeting_views.py
  14. 82
      apps/profiles/admin.py
  15. 137
      apps/profiles/migrations/0002_region_and_institution_region.py
  16. 3
      apps/profiles/models/__init__.py
  17. 26
      apps/profiles/models/institution.py
  18. 84
      apps/profiles/models/region.py
  19. 8
      apps/profiles/permissions.py
  20. 5
      apps/profiles/serializers/__init__.py
  21. 94
      apps/profiles/serializers/institution_serializers.py
  22. 124
      apps/profiles/tests/test_phase2_profiles.py
  23. 256
      apps/profiles/tests/test_regional_admin.py
  24. 6
      apps/profiles/urls.py
  25. 5
      apps/profiles/views/__init__.py
  26. 134
      apps/profiles/views/institution_views.py
  27. 13
      apps/projects/models/project.py
  28. 2
      apps/projects/permissions.py
  29. 35
      apps/projects/serializers/project_serializers.py
  30. 5
      config/settings/base.py
  31. BIN
      dump.rdb
  32. 388
      schema.yml

2090
apps/account/admin/access_modal_widget.py
File diff suppressed because it is too large
View File

43
apps/account/admin/user.py

@ -41,10 +41,23 @@ class UserAdminCreationForm(UserCreationForm):
import json 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): 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( groups = forms.ModelMultipleChoiceField(
queryset=Group.objects.all(), queryset=Group.objects.all(),
widget=GroupPillSelectMultipleWidget, widget=GroupPillSelectMultipleWidget,
@ -52,9 +65,9 @@ class UserAdminChangeForm(UserChangeForm):
label=_("Assigned Groups") label=_("Assigned Groups")
) )
custom_permissions = forms.CharField( custom_permissions = forms.CharField(
widget=RolePermissionsMatrixWidget,
widget=UserAccessManagementWidget,
required=False, required=False,
label=_("Application Permissions Matrix")
label=_("Role & Permissions Overview")
) )
class Meta(UserChangeForm.Meta): class Meta(UserChangeForm.Meta):
@ -64,6 +77,8 @@ class UserAdminChangeForm(UserChangeForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'email' in self.fields: if 'email' in self.fields:
self.fields['email'].required = True self.fields['email'].required = True
if 'custom_permissions' in self.fields:
self.fields['custom_permissions'].widget.instance = self.instance
def clean_custom_permissions(self): def clean_custom_permissions(self):
val = self.cleaned_data.get('custom_permissions') val = self.cleaned_data.get('custom_permissions')
@ -82,7 +97,7 @@ class UserAdmin(ModelAdmin, BaseUserAdmin):
add_form = UserAdminCreationForm add_form = UserAdminCreationForm
change_password_form = AdminPasswordChangeForm change_password_form = AdminPasswordChangeForm
compressed_fields = False 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') list_display_links = ('first_name', 'last_name', 'fullname', 'email')
ordering = ("-id",) ordering = ("-id",)
search_fields = ('email', 'first_name', 'last_name', 'fullname', 'username',) search_fields = ('email', 'first_name', 'last_name', 'fullname', 'username',)
@ -90,6 +105,7 @@ class UserAdmin(ModelAdmin, BaseUserAdmin):
list_filter = [ list_filter = [
"role", "role",
"user_type", "user_type",
"managed_regions",
"is_active", "is_active",
"is_staff", "is_staff",
("last_login", RangeDateTimeFilter), ("last_login", RangeDateTimeFilter),
@ -141,10 +157,11 @@ class UserAdmin(ModelAdmin, BaseUserAdmin):
(_("Access & Role"), { (_("Access & Role"), {
"classes": ["tab"], "classes": ["tab"],
"fields": ( "fields": (
("role", "user_type"),
("is_active", "is_staff", "is_superuser"), ("is_active", "is_staff", "is_superuser"),
"groups",
"custom_permissions", "custom_permissions",
("role", "user_type"),
"groups",
"managed_regions",
), ),
}), }),
(_("Raw Database Permissions"), { (_("Raw Database Permissions"), {
@ -169,6 +186,20 @@ class UserAdmin(ModelAdmin, BaseUserAdmin):
def display_date_joined(self, instance: User): def display_date_joined(self, instance: User):
return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-" 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('<span class="text-amber-500 font-medium">{}</span>', _("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): class GroupAdmin(BaseGroupAdmin, ModelAdmin):
list_display = ('name', 'permissions_count') list_display = ('name', 'permissions_count')

82
apps/account/admin/widgets.py

@ -4,6 +4,7 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from apps.account.models.role import Role, PERMISSION_CATEGORIES, ALL_PERMISSION_FIELDS from apps.account.models.role import Role, PERMISSION_CATEGORIES, ALL_PERMISSION_FIELDS
from apps.account.admin.access_modal_widget import UserAccessManagementWidget
CATEGORY_ICONS = { CATEGORY_ICONS = {
@ -102,6 +103,87 @@ class GroupPillSelectMultipleWidget(forms.CheckboxSelectMultiple):
return mark_safe('\n'.join(output)) 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 = [
'<div class="region-pills-wrapper my-2">',
' <div class="flex flex-wrap gap-2.5 items-center">'
]
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'<label class="{card_class}">'
f'<input type="checkbox" name="{escape(name)}" value="{escape(option_value)}" {checked_attr} class="hidden region-pill-input">'
f'<span class="{icon_class}">{icon_name}</span>'
f'<span class="pill-title">{escape(option_label)}</span>'
f'</label>'
)
if not has_choices:
output.append('<span class="text-xs text-slate-400 italic">No regions available in system.</span>')
output.append('''
</div>
<div class="text-xs text-slate-400 dark:text-slate-500 mt-2">
Click any region chip to assign or unassign geographic jurisdiction for this regional admin.
</div>
</div>
<script>
(function() {
document.addEventListener('change', function(e) {
if (e.target && e.target.classList.contains('region-pill-input')) {
const label = e.target.closest('.region-pill-card');
if (!label) return;
const icon = label.querySelector('.pill-icon');
if (e.target.checked) {
label.className = '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 (icon) {
icon.textContent = 'check_circle';
icon.className = 'material-symbols-outlined text-base text-emerald-600 dark:text-emerald-400 pill-icon';
}
} else {
label.className = '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-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';
if (icon) {
icon.textContent = 'public';
icon.className = 'material-symbols-outlined text-base text-slate-400 pill-icon';
}
}
}
});
})();
</script>
''')
return mark_safe('\n'.join(output))
class RolePermissionsMatrixWidget(forms.Widget): class RolePermissionsMatrixWidget(forms.Widget):
""" """
Renders an interactive, categorized visual permissions matrix. Renders an interactive, categorized visual permissions matrix.

33
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',
),
),
]

88
apps/account/models/user.py

@ -160,6 +160,19 @@ class User(AbstractUser):
verbose_name=_("Custom Permission Overrides"), verbose_name=_("Custom Permission Overrides"),
help_text=_("Dictionary of permission overrides: {permission_name: True/False}") 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() objects = UserManager()
@ -254,6 +267,81 @@ class User(AbstractUser):
def is_regional_admin(self): 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') 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 @property
def is_institution_admin(self): 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']) 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'])

19
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) bio = serializers.CharField(source='info', allow_blank=True, required=False, allow_null=True)
roles = serializers.SerializerMethodField() roles = serializers.SerializerMethodField()
permissions = serializers.SerializerMethodField() permissions = serializers.SerializerMethodField()
managed_regions = serializers.SerializerMethodField()
regional_permissions = serializers.ReadOnlyField()
verification_status = serializers.SerializerMethodField() verification_status = serializers.SerializerMethodField()
is_verified = serializers.SerializerMethodField() is_verified = serializers.SerializerMethodField()
first_name = serializers.SerializerMethodField() first_name = serializers.SerializerMethodField()
@ -143,6 +145,8 @@ class UserMeSerializer(serializers.ModelSerializer):
'is_staff', 'is_staff',
'roles', 'roles',
'permissions', 'permissions',
'managed_regions',
'regional_permissions',
'verification_status', 'verification_status',
'is_verified', 'is_verified',
] ]
@ -168,6 +172,8 @@ class UserMeSerializer(serializers.ModelSerializer):
'is_staff', 'is_staff',
'roles', 'roles',
'permissions', 'permissions',
'managed_regions',
'regional_permissions',
'verification_status', 'verification_status',
'is_verified', 'is_verified',
] ]
@ -268,6 +274,19 @@ class UserMeSerializer(serializers.ModelSerializer):
def get_is_verified(self, obj) -> bool: def get_is_verified(self, obj) -> bool:
return obj.verification_status == VerificationDocument.Status.VERIFIED 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): class UserMeUpdateSerializer(serializers.ModelSerializer):
bio = serializers.CharField(source='info', allow_blank=True, required=False, allow_null=True) bio = serializers.CharField(source='info', allow_blank=True, required=False, allow_null=True)

11
apps/geo_map/views.py

@ -63,6 +63,13 @@ class MapInstitutionsView(GenericAPIView):
if city: if city:
qs = qs.filter(city__iexact=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') inst_type = request.query_params.get('type')
if inst_type: if inst_type:
qs = qs.filter(type=inst_type) qs = qs.filter(type=inst_type)
@ -103,7 +110,7 @@ class MapInstitutionsView(GenericAPIView):
# Format items # Format items
institutions_list = [] 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 avatar_url = inst.avatar.url if inst.avatar else None
cover_url = inst.cover_image.url if inst.cover_image else None cover_url = inst.cover_image.url if inst.cover_image else None
institutions_list.append({ institutions_list.append({
@ -112,6 +119,8 @@ class MapInstitutionsView(GenericAPIView):
"slug": inst.slug, "slug": inst.slug,
"type": inst.type, "type": inst.type,
"type_display": inst.get_type_display(), "type_display": inst.get_type_display(),
"region": inst.region_id,
"region_name": inst.region.name if inst.region else None,
"lat": inst.latitude, "lat": inst.latitude,
"lng": inst.longitude, "lng": inst.longitude,
"city": inst.city, "city": inst.city,

2
apps/meetings/admin.py

@ -22,6 +22,8 @@ class MeetingRequestAdmin(ModelAdmin):
search_fields = ( search_fields = (
'title', 'title',
'agenda', 'agenda',
'admin_notes',
'rejection_reason',
'requester_institution__name', 'requester_institution__name',
'recipient_institution__name', 'recipient_institution__name',
'created_by__email', 'created_by__email',

18
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'),
),
]

1
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") meeting_url = models.URLField(max_length=500, blank=True, verbose_name="Video Meeting Link")
agenda = models.TextField(blank=True, verbose_name="Meeting Agenda & Topics") agenda = models.TextField(blank=True, verbose_name="Meeting Agenda & Topics")
admin_notes = models.TextField(blank=True, verbose_name="Internal / Admin Notes") 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

26
apps/meetings/serializers/meeting_serializers.py

@ -34,6 +34,7 @@ class MeetingListSerializer(serializers.ModelSerializer):
'meeting_time', 'meeting_time',
'duration_minutes', 'duration_minutes',
'meeting_url', 'meeting_url',
'rejection_reason',
'created_at', 'created_at',
] ]
@ -58,6 +59,7 @@ class MeetingDetailSerializer(serializers.ModelSerializer):
'meeting_url', 'meeting_url',
'agenda', 'agenda',
'admin_notes', 'admin_notes',
'rejection_reason',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ]
@ -117,6 +119,12 @@ class MeetingCreateSerializer(serializers.ModelSerializer):
class MeetingUpdateSerializer(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: class Meta:
model = MeetingRequest model = MeetingRequest
fields = [ fields = [
@ -127,4 +135,22 @@ class MeetingUpdateSerializer(serializers.ModelSerializer):
'meeting_url', 'meeting_url',
'agenda', 'agenda',
'admin_notes', '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

34
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['status'], MeetingStatus.SCHEDULED)
self.assertEqual(response.data['admin_notes'], "Accepted. Looking forward to meeting.") 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): def test_cancel_meeting(self):
url = reverse('meeting-detail', kwargs={'pk': self.meeting.id}) url = reverse('meeting-detail', kwargs={'pk': self.meeting.id})
self.client.force_authenticate(user=self.admin_a) self.client.force_authenticate(user=self.admin_a)

2
apps/meetings/views/meeting_views.py

@ -103,7 +103,7 @@ class MeetingListCreateView(generics.ListCreateAPIView):
patch=extend_schema( patch=extend_schema(
tags=['Meetings'], tags=['Meetings'],
summary="Update or respond to meeting request", 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, request=MeetingUpdateSerializer,
responses={200: MeetingDetailSerializer} responses={200: MeetingDetailSerializer}
), ),

82
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.admin import ModelAdmin, TabularInline, StackedInline
from unfold.decorators import display, action from unfold.decorators import display, action
from apps.profiles.models.institution import (
from apps.profiles.models import (
Region,
Institution, Institution,
InstitutionMember, InstitutionMember,
InstitutionTimeline, InstitutionTimeline,
@ -13,6 +14,44 @@ from apps.profiles.models.institution import (
from utils.admin import project_admin_site 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): class InstitutionMemberInline(TabularInline):
model = InstitutionMember model = InstitutionMember
extra = 0 extra = 0
@ -38,6 +77,7 @@ class InstitutionAdmin(ModelAdmin):
list_display = ( list_display = (
'name', 'name',
'type_badge', 'type_badge',
'region_display',
'city', 'city',
'country', 'country',
'verification_badge', 'verification_badge',
@ -47,19 +87,49 @@ class InstitutionAdmin(ModelAdmin):
'created_at', 'created_at',
) )
list_display_links = ('name', 'type_badge', 'city', 'country') 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',)} prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('follower_count', 'created_at', 'updated_at') readonly_fields = ('follower_count', 'created_at', 'updated_at')
inlines = [InstitutionMemberInline, InstitutionTimelineInline, InstitutionMediaInline] inlines = [InstitutionMemberInline, InstitutionTimelineInline, InstitutionMediaInline]
actions = ['approve_institutions', 'reject_institutions', 'toggle_featured'] 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 = ( fieldsets = (
(_('Identity & Classification'), { (_('Identity & Classification'), {
'fields': ('name', 'slug', 'type', 'is_featured', 'is_active', 'created_by') 'fields': ('name', 'slug', 'type', 'is_featured', 'is_active', 'created_by')
}), }),
(_('Location & Geo-Coordinates'), { (_('Location & Geo-Coordinates'), {
'fields': ('country', 'city', 'address', 'latitude', 'longitude')
'fields': ('region', 'country', 'city', 'address', 'latitude', 'longitude')
}), }),
(_('Contact & Official Channels'), { (_('Contact & Official Channels'), {
'fields': ('email', 'phone', 'website', 'established_year') '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')) @display(description=_('Type'))
def type_badge(self, obj): def type_badge(self, obj):
return obj.get_type_display() return obj.get_type_display()

137
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',
),
),
]

3
apps/profiles/models/__init__.py

@ -1,3 +1,4 @@
from .region import Region, get_default_region_pk
from .institution import ( from .institution import (
Institution, Institution,
InstitutionMember, InstitutionMember,
@ -7,6 +8,8 @@ from .institution import (
) )
__all__ = [ __all__ = [
'Region',
'get_default_region_pk',
'Institution', 'Institution',
'InstitutionMember', 'InstitutionMember',
'InstitutionTimeline', 'InstitutionTimeline',

26
apps/profiles/models/institution.py

@ -39,6 +39,13 @@ class Institution(models.Model):
default=InstitutionType.CULTURAL_CENTER, default=InstitutionType.CULTURAL_CENTER,
verbose_name=_('Institution Type') 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( country = models.CharField(
max_length=255, max_length=255,
verbose_name=_('Country') verbose_name=_('Country')
@ -161,6 +168,10 @@ class Institution(models.Model):
return f"{self.name} ({self.city}, {self.country})" return f"{self.name} ({self.city}, {self.country})"
def save(self, *args, **kwargs): 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: if not self.slug:
base_slug = slugify(self.name, allow_unicode=True) or f"institution-{random.randint(1000, 9999)}" base_slug = slugify(self.name, allow_unicode=True) or f"institution-{random.randint(1000, 9999)}"
slug = base_slug slug = base_slug
@ -174,7 +185,11 @@ class Institution(models.Model):
def is_admin(self, user): def is_admin(self, user):
if not user or not user.is_authenticated: if not user or not user.is_authenticated:
return False 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 True
return self.members.filter(user=user, role=InstitutionMember.MemberRole.ADMIN).exists() return self.members.filter(user=user, role=InstitutionMember.MemberRole.ADMIN).exists()
@ -183,6 +198,9 @@ class Institution(models.Model):
return False return False
if self.is_admin(user): if self.is_admin(user):
return True 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() return self.members.filter(user=user, role__in=[InstitutionMember.MemberRole.ADMIN, InstitutionMember.MemberRole.EDITOR]).exists()
def get_user_role(self, user): def get_user_role(self, user):
@ -191,9 +209,13 @@ class Institution(models.Model):
member = self.members.filter(user=user).first() member = self.members.filter(user=user).first()
if member: if member:
return member.role return member.role
if getattr(user, 'is_super_admin', False):
if getattr(user, 'is_super_admin', False) or user.is_superuser:
return 'super_admin' return 'super_admin'
if getattr(user, 'is_regional_admin', False): 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 'regional_admin'
return None return None

84
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

8
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: if not request.user or not request.user.is_authenticated or not request.user.is_active:
return False 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 return True
# Check if obj is Institution or a related model with an institution foreign key # 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: if not request.user or not request.user.is_authenticated or not request.user.is_active:
return False 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 return True
institution = obj if isinstance(obj, Institution) else getattr(obj, 'institution', None) 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: if not request.user or not request.user.is_authenticated or not request.user.is_active:
return False 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 return True
institution = obj if isinstance(obj, Institution) else getattr(obj, 'institution', None) institution = obj if isinstance(obj, Institution) else getattr(obj, 'institution', None)

5
apps/profiles/serializers/__init__.py

@ -1,4 +1,6 @@
from .institution_serializers import ( from .institution_serializers import (
RegionMiniSerializer,
RegionSerializer,
InstitutionTimelineSerializer, InstitutionTimelineSerializer,
InstitutionMediaSerializer, InstitutionMediaSerializer,
InstitutionMemberSerializer, InstitutionMemberSerializer,
@ -10,6 +12,8 @@ from .institution_serializers import (
) )
__all__ = [ __all__ = [
'RegionMiniSerializer',
'RegionSerializer',
'InstitutionTimelineSerializer', 'InstitutionTimelineSerializer',
'InstitutionMediaSerializer', 'InstitutionMediaSerializer',
'InstitutionMemberSerializer', 'InstitutionMemberSerializer',
@ -19,3 +23,4 @@ __all__ = [
'InstitutionCreateUpdateSerializer', 'InstitutionCreateUpdateSerializer',
'FollowResponseSerializer', 'FollowResponseSerializer',
] ]

94
apps/profiles/serializers/institution_serializers.py

@ -1,7 +1,9 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from rest_framework import serializers from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field 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, Institution,
InstitutionMember, InstitutionMember,
InstitutionTimeline, InstitutionTimeline,
@ -12,6 +14,33 @@ from apps.profiles.models.institution import (
User = get_user_model() 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 InstitutionTimelineSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = InstitutionTimeline model = InstitutionTimeline
@ -99,6 +128,9 @@ def _resolve_institution_media_url(image_field, request, fallback_url=None):
class InstitutionListSerializer(serializers.ModelSerializer): class InstitutionListSerializer(serializers.ModelSerializer):
type_display = serializers.CharField(source='get_type_display', read_only=True) type_display = serializers.CharField(source='get_type_display', read_only=True)
verification_status_display = serializers.CharField(source='get_verification_status_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() is_following = serializers.SerializerMethodField()
avatar = serializers.SerializerMethodField() avatar = serializers.SerializerMethodField()
cover_image = serializers.SerializerMethodField() cover_image = serializers.SerializerMethodField()
@ -111,6 +143,9 @@ class InstitutionListSerializer(serializers.ModelSerializer):
'slug', 'slug',
'type', 'type',
'type_display', 'type_display',
'region',
'region_id',
'region_name',
'country', 'country',
'city', 'city',
'address', 'address',
@ -149,6 +184,9 @@ class InstitutionListSerializer(serializers.ModelSerializer):
class InstitutionDetailSerializer(serializers.ModelSerializer): class InstitutionDetailSerializer(serializers.ModelSerializer):
type_display = serializers.CharField(source='get_type_display', read_only=True) type_display = serializers.CharField(source='get_type_display', read_only=True)
verification_status_display = serializers.CharField(source='get_verification_status_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) timeline_entries = InstitutionTimelineSerializer(many=True, read_only=True)
media_items = InstitutionMediaSerializer(many=True, read_only=True) media_items = InstitutionMediaSerializer(many=True, read_only=True)
projects = serializers.SerializerMethodField() projects = serializers.SerializerMethodField()
@ -168,6 +206,9 @@ class InstitutionDetailSerializer(serializers.ModelSerializer):
'slug', 'slug',
'type', 'type',
'type_display', 'type_display',
'region',
'region_id',
'region_name',
'country', 'country',
'city', 'city',
'address', 'address',
@ -202,6 +243,9 @@ class InstitutionDetailSerializer(serializers.ModelSerializer):
read_only_fields = [ read_only_fields = [
'id', 'id',
'slug', 'slug',
'region',
'region_id',
'region_name',
'verification_status', 'verification_status',
'verification_status_display', 'verification_status_display',
'follower_count', 'follower_count',
@ -349,11 +393,19 @@ class InstitutionDetailSerializer(serializers.ModelSerializer):
class InstitutionCreateUpdateSerializer(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: class Meta:
model = Institution model = Institution
fields = [ fields = [
'name', 'name',
'type', 'type',
'region',
'country', 'country',
'city', 'city',
'address', 'address',
@ -378,6 +430,17 @@ class InstitutionCreateUpdateSerializer(serializers.ModelSerializer):
else: else:
data = dict(data) 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 # Normalize institution type if passed in Persian or other aliases
inst_type = data.get('type') inst_type = data.get('type')
if inst_type: if inst_type:
@ -434,14 +497,37 @@ class InstitutionCreateUpdateSerializer(serializers.ModelSerializer):
return super().to_internal_value(data) 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): def create(self, validated_data):
user = self.context.get('request').user if self.context.get('request') else None user = self.context.get('request').user if self.context.get('request') else None
if user and user.is_authenticated: if user and user.is_authenticated:
validated_data['created_by'] = user 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) institution = super().create(validated_data)

124
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.test import APIClient
from rest_framework import status from rest_framework import status
from apps.profiles.models.institution import (
from apps.profiles.models import (
Region,
Institution, Institution,
InstitutionMember, InstitutionMember,
InstitutionTimeline, InstitutionTimeline,
@ -37,6 +38,7 @@ class Phase2InstitutionProfileTests(TestCase):
password="SecurePassword123!", password="SecurePassword123!",
fullname="Sheikh Hassan Al-Berlini", fullname="Sheikh Hassan Al-Berlini",
user_type=User.UserType.INSTITUTION_ADMIN, user_type=User.UserType.INSTITUTION_ADMIN,
is_staff=True,
country="Germany", country="Germany",
city="Berlin" city="Berlin"
) )
@ -57,10 +59,19 @@ class Phase2InstitutionProfileTests(TestCase):
city="Frankfurt" city="Frankfurt"
) )
# Region
self.region = Region.objects.create(
name="Central Europe",
slug="central-europe",
code="CEU",
description="Central European zone"
)
# Primary Institution # Primary Institution
self.institution = Institution.objects.create( self.institution = Institution.objects.create(
name="Imam Ali Islamic Center Berlin", name="Imam Ali Islamic Center Berlin",
type=Institution.InstitutionType.CULTURAL_CENTER, type=Institution.InstitutionType.CULTURAL_CENTER,
region=self.region,
country="Germany", country="Germany",
city="Berlin", city="Berlin",
address="Flughafenstrasse 42, 12053 Berlin", address="Flughafenstrasse 42, 12053 Berlin",
@ -105,10 +116,20 @@ class Phase2InstitutionProfileTests(TestCase):
"established_year": 2005, "established_year": 2005,
"tags": ["Library", "Language Courses"] "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") response = self.client.post("/api/v1/profiles/", payload, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["name"], "Al-Zahra Cultural Center Vienna") self.assertEqual(response.data["name"], "Al-Zahra Cultural Center Vienna")
self.assertTrue(response.data["slug"]) self.assertTrue(response.data["slug"])
self.assertEqual(response.data["region"]["id"], self.region.id)
self.assertEqual(response.data["verification_status"], "pending") self.assertEqual(response.data["verification_status"], "pending")
# Verify Creator is registered as Admin member # 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}/") 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(del_member_resp.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(InstitutionMember.objects.filter(id=member_id).count(), 0) 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)

256
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="[email protected]",
password="SecurePassword123!",
fullname="Global Super Admin"
)
# Create Regional Admin assigned to Europe only
self.eu_admin = User.objects.create_user(
email="[email protected]",
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="[email protected]",
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")

6
apps/profiles/urls.py

@ -1,5 +1,7 @@
from django.urls import path from django.urls import path
from apps.profiles.views import ( from apps.profiles.views import (
RegionListCreateView,
RegionDetailView,
InstitutionListCreateView, InstitutionListCreateView,
InstitutionDetailView, InstitutionDetailView,
InstitutionTimelineView, InstitutionTimelineView,
@ -12,6 +14,10 @@ from apps.profiles.views import (
) )
urlpatterns = [ urlpatterns = [
# Geographical Regions CRUD & Dropdown
path('regions/', RegionListCreateView.as_view(), name='region_list_create'),
path('regions/<str:pk_or_slug>/', RegionDetailView.as_view(), name='region_detail'),
# Organization Profiles CRUD & Search # Organization Profiles CRUD & Search
path('', InstitutionListCreateView.as_view(), name='institution_list_create'), path('', InstitutionListCreateView.as_view(), name='institution_list_create'),
path('<str:pk_or_slug>/', InstitutionDetailView.as_view(), name='institution_detail'), path('<str:pk_or_slug>/', InstitutionDetailView.as_view(), name='institution_detail'),

5
apps/profiles/views/__init__.py

@ -1,4 +1,6 @@
from .institution_views import ( from .institution_views import (
RegionListCreateView,
RegionDetailView,
InstitutionListCreateView, InstitutionListCreateView,
InstitutionDetailView, InstitutionDetailView,
InstitutionTimelineView, InstitutionTimelineView,
@ -11,6 +13,8 @@ from .institution_views import (
) )
__all__ = [ __all__ = [
'RegionListCreateView',
'RegionDetailView',
'InstitutionListCreateView', 'InstitutionListCreateView',
'InstitutionDetailView', 'InstitutionDetailView',
'InstitutionTimelineView', 'InstitutionTimelineView',
@ -21,3 +25,4 @@ __all__ = [
'InstitutionMembersView', 'InstitutionMembersView',
'InstitutionMemberDetailView', 'InstitutionMemberDetailView',
] ]

134
apps/profiles/views/institution_views.py

@ -10,7 +10,8 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse, inline_serializer from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse, inline_serializer
from apps.profiles.models.institution import (
from apps.profiles.models import (
Region,
Institution, Institution,
InstitutionMember, InstitutionMember,
InstitutionTimeline, InstitutionTimeline,
@ -23,6 +24,8 @@ from apps.profiles.permissions import (
CanManageInstitutionMembers, CanManageInstitutionMembers,
) )
from apps.profiles.serializers import ( from apps.profiles.serializers import (
RegionMiniSerializer,
RegionSerializer,
InstitutionListSerializer, InstitutionListSerializer,
InstitutionDetailSerializer, InstitutionDetailSerializer,
InstitutionCreateUpdateSerializer, InstitutionCreateUpdateSerializer,
@ -38,6 +41,111 @@ logger = logging.getLogger(__name__)
User = get_user_model() 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): def get_institution_by_id_or_slug(lookup_val):
if str(lookup_val).isdigit(): if str(lookup_val).isdigit():
return get_object_or_404(Institution, id=int(lookup_val), is_active=True) 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): if getattr(self, 'swagger_fake_view', False):
return Institution.objects.none() return Institution.objects.none()
qs = Institution.objects.filter(is_active=True)
qs = Institution.objects.filter(is_active=True).select_related('region')
# Filters # Filters
search = self.request.query_params.get('search') search = self.request.query_params.get('search')
@ -66,9 +174,17 @@ class InstitutionListCreateView(GenericAPIView):
Q(name__icontains=search) | Q(name__icontains=search) |
Q(city__icontains=search) | Q(city__icontains=search) |
Q(country__icontains=search) | Q(country__icontains=search) |
Q(region__name__icontains=search) |
Q(description__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') country = self.request.query_params.get('country')
if country: if country:
qs = qs.filter(country__iexact=country) qs = qs.filter(country__iexact=country)
@ -93,13 +209,25 @@ class InstitutionListCreateView(GenericAPIView):
if verification: if verification:
qs = qs.filter(verification_status=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') return qs.order_by('-is_featured', '-created_at')
@extend_schema( @extend_schema(
summary="List active institutions", summary="List active institutions",
description="Returns a paginated list of mosques, cultural centers, and institutes with filtering.", description="Returns a paginated list of mosques, cultural centers, and institutes with filtering.",
parameters=[ 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('country', str, description='Filter by country name'),
OpenApiParameter('city', str, description='Filter by city name'), OpenApiParameter('city', str, description='Filter by city name'),
OpenApiParameter('type', str, description='Filter by institution type (mosque, cultural_center, etc.)'), OpenApiParameter('type', str, description='Filter by institution type (mosque, cultural_center, etc.)'),

13
apps/projects/models/project.py

@ -151,10 +151,18 @@ class Project(models.Model):
def can_user_edit(self, user): def can_user_edit(self, user):
if not user or not user.is_authenticated: if not user or not user.is_authenticated:
return False 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 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 # Check owner institution
if self.owner_institution.is_editor(user):
if self.owner_institution and self.owner_institution.is_editor(user):
return True return True
# Check collaborating institutions # Check collaborating institutions
for collab in self.collaborating_institutions.all(): for collab in self.collaborating_institutions.all():
@ -163,6 +171,7 @@ class Project(models.Model):
return False return False
class KanbanColumn(models.Model): class KanbanColumn(models.Model):
project = models.ForeignKey( project = models.ForeignKey(
Project, Project,

2
apps/projects/permissions.py

@ -22,7 +22,7 @@ class IsProjectParticipantOrReadOnly(BasePermission):
if not request.user or not request.user.is_authenticated: if not request.user or not request.user.is_authenticated:
return False 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 return True
# Resolve the parent Project object # Resolve the parent Project object

35
apps/projects/serializers/project_serializers.py

@ -242,6 +242,41 @@ class ProjectCreateUpdateSerializer(serializers.ModelSerializer):
'actual_end_date', '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): def create(self, validated_data):
request = self.context.get('request') request = self.context.get('request')
collaborating = validated_data.pop('collaborating_institutions', []) collaborating = validated_data.pop('collaborating_institutions', [])

5
config/settings/base.py

@ -447,6 +447,11 @@ UNFOLD = {
"icon": "account_balance", "icon": "account_balance",
"link": lambda request: admin_url_generator(request, "profiles_institution_changelist"), "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"), "title": _("Institution Members"),
"icon": "badge", "icon": "badge",

BIN
dump.rdb

388
schema.yml

@ -4091,8 +4091,8 @@ paths:
description: '' description: ''
patch: patch:
operationId: v1_meetings_partial_update 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 summary: Update or respond to meeting request
parameters: parameters:
- in: path - in: path
@ -4167,6 +4167,11 @@ paths:
schema: schema:
type: boolean type: boolean
description: Filter by featured status 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 - name: page
required: false required: false
in: query in: query
@ -4179,11 +4184,16 @@ paths:
description: Number of results to return per page. description: Number of results to return per page.
schema: schema:
type: integer type: integer
- in: query
name: region
schema:
type: string
description: Filter by region ID, slug, or name
- in: query - in: query
name: search name: search
schema: schema:
type: string type: string
description: Search query across name, city, country, description
description: Search query across name, city, country, region, description
- in: query - in: query
name: tag name: tag
schema: schema:
@ -4706,6 +4716,156 @@ paths:
responses: responses:
'204': '204':
description: Entry deleted 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/: /api/v1/projects/:
get: get:
operationId: v1_projects_list operationId: v1_projects_list
@ -8042,6 +8202,9 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/InstitutionTypeEnum' - $ref: '#/components/schemas/InstitutionTypeEnum'
title: Institution Type title: Institution Type
region:
type: integer
description: Region ID this institution belongs to (required).
country: country:
type: string type: string
minLength: 1 minLength: 1
@ -8115,6 +8278,7 @@ components:
- city - city
- country - country
- name - name
- region
InstitutionDetail: InstitutionDetail:
type: object type: object
properties: properties:
@ -8139,6 +8303,16 @@ components:
type_display: type_display:
type: string type: string
readOnly: true readOnly: true
region:
allOf:
- $ref: '#/components/schemas/Region'
readOnly: true
region_id:
type: integer
readOnly: true
region_name:
type: string
readOnly: true
country: country:
type: string type: string
maxLength: 255 maxLength: 255
@ -8189,13 +8363,12 @@ components:
title: Established Year (CE) title: Established Year (CE)
cover_image: cover_image:
type: string type: string
format: uri
nullable: true nullable: true
readOnly: true
avatar: avatar:
type: string type: string
format: uri
nullable: true nullable: true
title: Logo / Avatar
readOnly: true
social_media: social_media:
title: Social Media Links title: Social Media Links
description: 'Dictionary with keys: facebook, twitter, instagram, linkedin, description: 'Dictionary with keys: facebook, twitter, instagram, linkedin,
@ -8246,6 +8419,14 @@ components:
items: items:
$ref: '#/components/schemas/InstitutionMedia' $ref: '#/components/schemas/InstitutionMedia'
readOnly: true readOnly: true
projects:
type: array
items: {}
readOnly: true
posts:
type: array
items: {}
readOnly: true
created_at: created_at:
type: string type: string
format: date-time format: date-time
@ -8255,8 +8436,10 @@ components:
format: date-time format: date-time
readOnly: true readOnly: true
required: required:
- avatar
- city - city
- country - country
- cover_image
- created_at - created_at
- created_by_email - created_by_email
- follower_count - follower_count
@ -8267,6 +8450,11 @@ components:
- media_items - media_items
- members_count - members_count
- name - name
- posts
- projects
- region
- region_id
- region_name
- slug - slug
- timeline_entries - timeline_entries
- type_display - type_display
@ -8299,6 +8487,16 @@ components:
type_display: type_display:
type: string type: string
readOnly: true readOnly: true
region:
allOf:
- $ref: '#/components/schemas/RegionMini'
readOnly: true
region_id:
type: integer
readOnly: true
region_name:
type: string
readOnly: true
country: country:
type: string type: string
readOnly: true readOnly: true
@ -8322,13 +8520,10 @@ components:
nullable: true nullable: true
avatar: avatar:
type: string type: string
format: uri
nullable: true nullable: true
readOnly: true readOnly: true
title: Logo / Avatar
cover_image: cover_image:
type: string type: string
format: uri
nullable: true nullable: true
readOnly: true readOnly: true
verification_status: verification_status:
@ -8369,6 +8564,9 @@ components:
- latitude - latitude
- longitude - longitude
- name - name
- region
- region_id
- region_name
- slug - slug
- tags - tags
- type - type
@ -9315,6 +9513,9 @@ components:
admin_notes: admin_notes:
type: string type: string
title: Internal / Admin Notes title: Internal / Admin Notes
rejection_reason:
type: string
title: Rejection / Decline Reason
created_at: created_at:
type: string type: string
format: date-time format: date-time
@ -9376,6 +9577,9 @@ components:
maxLength: 500 maxLength: 500
- type: string - type: string
maxLength: 0 maxLength: 0
rejection_reason:
type: string
title: Rejection / Decline Reason
created_at: created_at:
type: string type: string
format: date-time format: date-time
@ -9433,6 +9637,9 @@ components:
admin_notes: admin_notes:
type: string type: string
title: Internal / Admin Notes title: Internal / Admin Notes
rejection_reason:
type: string
description: Required explanation when rejecting/declining a meeting request.
required: required:
- meeting_date - meeting_date
- meeting_time - meeting_time
@ -9466,6 +9673,9 @@ components:
admin_notes: admin_notes:
type: string type: string
title: Internal / Admin Notes title: Internal / Admin Notes
rejection_reason:
type: string
description: Required explanation when rejecting/declining a meeting request.
required: required:
- meeting_date - meeting_date
- meeting_time - meeting_time
@ -10276,6 +10486,29 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/ProjectList' $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: PaginatedRegionalDensityList:
type: object type: object
required: required:
@ -10629,6 +10862,9 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/InstitutionTypeEnum' - $ref: '#/components/schemas/InstitutionTypeEnum'
title: Institution Type title: Institution Type
region:
type: integer
description: Region ID this institution belongs to (required).
country: country:
type: string type: string
minLength: 1 minLength: 1
@ -10758,6 +10994,9 @@ components:
admin_notes: admin_notes:
type: string type: string
title: Internal / Admin Notes title: Internal / Admin Notes
rejection_reason:
type: string
description: Required explanation when rejecting/declining a meeting request.
PatchedNotificationTemplateRequest: PatchedNotificationTemplateRequest:
type: object type: object
properties: properties:
@ -10896,6 +11135,27 @@ components:
format: date format: date
nullable: true nullable: true
title: Actual Completion Date 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: PatchedRepresentativeReportReviewRequest:
type: object type: object
properties: properties:
@ -12141,6 +12401,53 @@ components:
$ref: '#/components/schemas/SingleQuizAnswerRequest' $ref: '#/components/schemas/SingleQuizAnswerRequest'
required: required:
- answers - 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: RegionInfo:
type: object type: object
properties: properties:
@ -12156,6 +12463,58 @@ components:
- browser - browser
- ip - ip
- user_agent - 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: RegionalDensity:
type: object type: object
properties: properties:
@ -13225,6 +13584,15 @@ components:
type: array type: array
items: {} items: {}
readOnly: true 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: verification_status:
type: string type: string
readOnly: true readOnly: true
@ -13243,12 +13611,14 @@ components:
- is_staff - is_staff
- is_verified - is_verified
- last_name - last_name
- managed_regions
- name - name
- permissions - permissions
- phone - phone
- position - position
- preferred_language - preferred_language
- rank - rank
- regional_permissions
- roles - roles
- title - title
- user_type - user_type

Loading…
Cancel
Save