20 changed files with 18271 additions and 144 deletions
-
29apps/account/admin/user.py
-
39apps/dynamic_forms/permissions.py
-
9apps/dynamic_forms/serializers/form_serializers.py
-
156apps/dynamic_forms/tests/test_phase9_forms.py
-
51apps/dynamic_forms/views/form_views.py
-
34apps/profiles/admin.py
-
89apps/profiles/management/commands/seed_quick_access_links.py
-
38apps/profiles/migrations/0003_quickaccesslink.py
-
3apps/profiles/models/__init__.py
-
83apps/profiles/models/quick_access_link.py
-
8apps/profiles/permissions.py
-
5apps/profiles/serializers/__init__.py
-
58apps/profiles/serializers/institution_serializers.py
-
176apps/profiles/tests/test_quick_access_links.py
-
9apps/profiles/urls.py
-
9apps/profiles/views/__init__.py
-
181apps/profiles/views/institution_views.py
-
BINdump.rdb
-
15588openapi.json
-
1850schema.yml
@ -0,0 +1,39 @@ |
|||||
|
from rest_framework.permissions import BasePermission |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
|
||||
|
|
||||
|
class CanCreateDynamicForm(BasePermission): |
||||
|
""" |
||||
|
Permission to create dynamic forms: |
||||
|
1. User must be authenticated and active. |
||||
|
2. Platform staff, superusers, or regional admins are permitted. |
||||
|
3. Regular users must have an associated institution AND |
||||
|
the 'can_create_forms' permission flag (toggled via role or user custom_permissions in panel admin). |
||||
|
""" |
||||
|
message = _("Only users affiliated with an institution and granted form creation permissions can create dynamic forms.") |
||||
|
|
||||
|
def has_permission(self, request, view): |
||||
|
user = request.user |
||||
|
if not user or not user.is_authenticated or not user.is_active: |
||||
|
return False |
||||
|
|
||||
|
if user.is_staff or getattr(user, 'is_super_admin', False) or getattr(user, 'is_regional_admin', False) or user.is_superuser: |
||||
|
return True |
||||
|
|
||||
|
# Check if user has an associated institution |
||||
|
has_institution = bool( |
||||
|
(hasattr(user, 'institution_memberships') and user.institution_memberships.exists()) or |
||||
|
(hasattr(user, 'created_institutions') and user.created_institutions.exists()) or |
||||
|
bool(getattr(user, 'institution_name', None)) |
||||
|
) |
||||
|
if not has_institution: |
||||
|
self.message = _("Only users affiliated with an institution can create dynamic forms.") |
||||
|
return False |
||||
|
|
||||
|
# Check togglable permission flag from panel admin (Role or custom_permissions) |
||||
|
has_permission_flag = user.has_perm_flag('can_create_forms') if hasattr(user, 'has_perm_flag') else False |
||||
|
if not has_permission_flag: |
||||
|
self.message = _("You do not have permission to create dynamic forms. Please contact an administrator.") |
||||
|
return False |
||||
|
|
||||
|
return True |
||||
@ -0,0 +1,89 @@ |
|||||
|
from django.core.management.base import BaseCommand |
||||
|
from apps.profiles.models import Institution, QuickAccessLink |
||||
|
|
||||
|
PRESETS = [ |
||||
|
{ |
||||
|
"title": "زیارت مجازی روضه منوره", |
||||
|
"url": "https://tv.razavi.ir/virtual-tour", |
||||
|
"description": "نمای سهبعدی و ۳۶۰ درجه از حرم مطهر رضوی، روضه منوره و صحنهای متبرکه", |
||||
|
"icon": "landmark", |
||||
|
"color": "emerald", |
||||
|
"category": "زیارت مجازی", |
||||
|
"is_live": False, |
||||
|
"is_external": True, |
||||
|
"order": 1, |
||||
|
}, |
||||
|
{ |
||||
|
"title": "پخش زنده حرم مطهر رضوی", |
||||
|
"url": "https://tv.razavi.ir/live", |
||||
|
"description": "پخش مستقیم و ۲۴ ساعته از صحن انقلاب، گوهرشاد و ضریح مطهر", |
||||
|
"icon": "video", |
||||
|
"color": "rose", |
||||
|
"category": "پخش زنده", |
||||
|
"is_live": True, |
||||
|
"is_external": True, |
||||
|
"order": 2, |
||||
|
}, |
||||
|
{ |
||||
|
"title": "کتابخانه دیجیتال آستان قدس", |
||||
|
"url": "https://digital.razavi.ir", |
||||
|
"description": "دسترسی و جستجو در صدها هزار نسخه خطی، اسناد تاریخی و کتب معارف", |
||||
|
"icon": "bookOpen", |
||||
|
"color": "blue", |
||||
|
"category": "کتابخانه دیجیتال", |
||||
|
"is_live": False, |
||||
|
"is_external": True, |
||||
|
"order": 3, |
||||
|
}, |
||||
|
{ |
||||
|
"title": "مرکز تولیدات چندرسانهای", |
||||
|
"url": "https://media.razavi.ir", |
||||
|
"description": "آرشیو تواشیح، نماهنگها، سخنرانیهای مذهبی، پادکستها و مستندات چندزبانه", |
||||
|
"icon": "film", |
||||
|
"color": "purple", |
||||
|
"category": "چندرسانهای", |
||||
|
"is_live": False, |
||||
|
"is_external": True, |
||||
|
"order": 4, |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = "Seed canonical quick access links for primary institution (e.g. Astan Quds / ID 1)" |
||||
|
|
||||
|
def add_arguments(self, parser): |
||||
|
parser.add_argument('--institution-id', type=int, default=1, help='Institution ID to attach links to') |
||||
|
|
||||
|
def handle(self, *args, **options): |
||||
|
inst_id = options.get('institution_id', 1) |
||||
|
institution = Institution.objects.filter(id=inst_id).first() or Institution.objects.first() |
||||
|
if not institution: |
||||
|
self.stdout.write(self.style.WARNING("No institution found to seed quick access links.")) |
||||
|
return |
||||
|
|
||||
|
created_count = 0 |
||||
|
for item in PRESETS: |
||||
|
link, created = QuickAccessLink.objects.update_or_create( |
||||
|
institution=institution, |
||||
|
title=item['title'], |
||||
|
defaults={ |
||||
|
'url': item['url'], |
||||
|
'description': item['description'], |
||||
|
'icon': item['icon'], |
||||
|
'color': item['color'], |
||||
|
'category': item['category'], |
||||
|
'is_live': item['is_live'], |
||||
|
'is_external': item['is_external'], |
||||
|
'order': item['order'], |
||||
|
'is_active': True, |
||||
|
} |
||||
|
) |
||||
|
if created: |
||||
|
created_count += 1 |
||||
|
|
||||
|
self.stdout.write( |
||||
|
self.style.SUCCESS( |
||||
|
f"Successfully seeded canonical quick access links for '{institution.name}' (ID: {institution.id})." |
||||
|
) |
||||
|
) |
||||
@ -0,0 +1,38 @@ |
|||||
|
# Generated by Django 4.2.30 on 2026-10-18 12:18 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
import django.db.models.deletion |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('profiles', '0002_region_and_institution_region'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='QuickAccessLink', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('title', models.CharField(max_length=255, verbose_name='عنوان خدمت')), |
||||
|
('url', models.CharField(max_length=500, verbose_name='نشانی اینترنتی یا مسیر')), |
||||
|
('description', models.CharField(blank=True, default='', max_length=500, verbose_name='توضیح مختصر')), |
||||
|
('icon', models.CharField(default='link', max_length=50, verbose_name='شناسه آیکون')), |
||||
|
('color', models.CharField(choices=[('emerald', 'سبز رضوی (Emerald)'), ('rose', 'سرخ زنده (Rose)'), ('blue', 'آبی دانش (Blue)'), ('purple', 'بنفش چندرسانه\u200cای (Purple)'), ('amber', 'طلایی ویژه (Amber)'), ('teal', 'فیروزه\u200cای (Teal)')], default='emerald', max_length=20, verbose_name='طیف رنگی')), |
||||
|
('category', models.CharField(blank=True, default='', max_length=100, verbose_name='برچسب یا دسته\u200cبندی')), |
||||
|
('is_live', models.BooleanField(default=False, verbose_name='نشانگر پخش زنده')), |
||||
|
('is_external', models.BooleanField(default=True, verbose_name='باز شدن در برگه جدید')), |
||||
|
('order', models.PositiveIntegerField(default=0, verbose_name='ترتیب نمایش')), |
||||
|
('is_active', models.BooleanField(default=True, verbose_name='فعال بودن')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='آخرین بروزرسانی')), |
||||
|
('institution', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quick_access_links', to='profiles.institution', verbose_name='مؤسسه مربوطه')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'لینک دسترسی سریع', |
||||
|
'verbose_name_plural': 'لینک\u200cهای دسترسی سریع', |
||||
|
'ordering': ['order', 'id'], |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,83 @@ |
|||||
|
from django.db import models |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
|
||||
|
|
||||
|
class QuickAccessLink(models.Model): |
||||
|
class ColorChoice(models.TextChoices): |
||||
|
EMERALD = "emerald", _("سبز رضوی (Emerald)") |
||||
|
ROSE = "rose", _("سرخ زنده (Rose)") |
||||
|
BLUE = "blue", _("آبی دانش (Blue)") |
||||
|
PURPLE = "purple", _("بنفش چندرسانهای (Purple)") |
||||
|
AMBER = "amber", _("طلایی ویژه (Amber)") |
||||
|
TEAL = "teal", _("فیروزهای (Teal)") |
||||
|
|
||||
|
institution = models.ForeignKey( |
||||
|
"profiles.Institution", |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name="quick_access_links", |
||||
|
verbose_name=_("مؤسسه مربوطه"), |
||||
|
) |
||||
|
title = models.CharField( |
||||
|
max_length=255, |
||||
|
verbose_name=_("عنوان خدمت") |
||||
|
) |
||||
|
url = models.CharField( |
||||
|
max_length=500, |
||||
|
verbose_name=_("نشانی اینترنتی یا مسیر") |
||||
|
) |
||||
|
description = models.CharField( |
||||
|
max_length=500, |
||||
|
blank=True, |
||||
|
default="", |
||||
|
verbose_name=_("توضیح مختصر") |
||||
|
) |
||||
|
icon = models.CharField( |
||||
|
max_length=50, |
||||
|
default="link", |
||||
|
verbose_name=_("شناسه آیکون") |
||||
|
) |
||||
|
color = models.CharField( |
||||
|
max_length=20, |
||||
|
choices=ColorChoice.choices, |
||||
|
default=ColorChoice.EMERALD, |
||||
|
verbose_name=_("طیف رنگی"), |
||||
|
) |
||||
|
category = models.CharField( |
||||
|
max_length=100, |
||||
|
blank=True, |
||||
|
default="", |
||||
|
verbose_name=_("برچسب یا دستهبندی") |
||||
|
) |
||||
|
is_live = models.BooleanField( |
||||
|
default=False, |
||||
|
verbose_name=_("نشانگر پخش زنده") |
||||
|
) |
||||
|
is_external = models.BooleanField( |
||||
|
default=True, |
||||
|
verbose_name=_("باز شدن در برگه جدید") |
||||
|
) |
||||
|
order = models.PositiveIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_("ترتیب نمایش") |
||||
|
) |
||||
|
is_active = models.BooleanField( |
||||
|
default=True, |
||||
|
verbose_name=_("فعال بودن") |
||||
|
) |
||||
|
|
||||
|
created_at = models.DateTimeField( |
||||
|
auto_now_add=True, |
||||
|
verbose_name=_("تاریخ ایجاد") |
||||
|
) |
||||
|
updated_at = models.DateTimeField( |
||||
|
auto_now=True, |
||||
|
verbose_name=_("آخرین بروزرسانی") |
||||
|
) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _("لینک دسترسی سریع") |
||||
|
verbose_name_plural = _("لینکهای دسترسی سریع") |
||||
|
ordering = ["order", "id"] |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.title} ({self.institution.name})" |
||||
@ -0,0 +1,176 @@ |
|||||
|
from django.test import TestCase |
||||
|
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, QuickAccessLink |
||||
|
|
||||
|
User = get_user_model() |
||||
|
|
||||
|
|
||||
|
class QuickAccessLinkAPITests(TestCase): |
||||
|
def setUp(self): |
||||
|
self.client = APIClient() |
||||
|
|
||||
|
self.region = Region.objects.create(name="Middle East", code="ME", slug="middle-east") |
||||
|
|
||||
|
self.admin_user = User.objects.create_user( |
||||
|
email="admin@razavi.ir", |
||||
|
password="SecurePassword123!", |
||||
|
fullname="Admin User", |
||||
|
user_type=User.UserType.INSTITUTION_ADMIN, |
||||
|
is_staff=True, |
||||
|
) |
||||
|
|
||||
|
self.other_user = User.objects.create_user( |
||||
|
email="other@example.com", |
||||
|
password="SecurePassword123!", |
||||
|
fullname="Regular User", |
||||
|
user_type=User.UserType.CLIENT, |
||||
|
) |
||||
|
|
||||
|
self.institution = Institution.objects.create( |
||||
|
name="Astan Quds Razavi", |
||||
|
slug="astan-quds", |
||||
|
region=self.region, |
||||
|
country="Iran", |
||||
|
city="Mashhad", |
||||
|
created_by=self.admin_user, |
||||
|
) |
||||
|
|
||||
|
InstitutionMember.objects.create( |
||||
|
institution=self.institution, |
||||
|
user=self.admin_user, |
||||
|
role=InstitutionMember.MemberRole.ADMIN, |
||||
|
) |
||||
|
|
||||
|
self.link1 = QuickAccessLink.objects.create( |
||||
|
institution=self.institution, |
||||
|
title="زیارت مجازی روضه منوره", |
||||
|
url="https://tv.razavi.ir/virtual-tour", |
||||
|
description="نمای سهبعدی و ۳۶۰ درجه از حرم مطهر رضوی", |
||||
|
icon="landmark", |
||||
|
color=QuickAccessLink.ColorChoice.EMERALD, |
||||
|
category="زیارت مجازی", |
||||
|
is_live=False, |
||||
|
is_external=True, |
||||
|
order=1, |
||||
|
is_active=True, |
||||
|
) |
||||
|
|
||||
|
self.link2 = QuickAccessLink.objects.create( |
||||
|
institution=self.institution, |
||||
|
title="پخش زنده حرم مطهر رضوی", |
||||
|
url="https://tv.razavi.ir/live", |
||||
|
description="پخش مستقیم و ۲۴ ساعته", |
||||
|
icon="video", |
||||
|
color=QuickAccessLink.ColorChoice.ROSE, |
||||
|
category="پخش زنده", |
||||
|
is_live=True, |
||||
|
is_external=True, |
||||
|
order=2, |
||||
|
is_active=False, # Inactive link to test filtering |
||||
|
) |
||||
|
|
||||
|
def test_embedded_quick_links_in_institution_detail(self): |
||||
|
# Public user should only see active link |
||||
|
response = self.client.get(f"/api/v1/profiles/{self.institution.id}/") |
||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK) |
||||
|
self.assertIn("quick_access_links", response.data) |
||||
|
links = response.data["quick_access_links"] |
||||
|
self.assertEqual(len(links), 1) |
||||
|
self.assertEqual(links[0]["id"], self.link1.id) |
||||
|
|
||||
|
# Admin user should see all links |
||||
|
self.client.force_authenticate(user=self.admin_user) |
||||
|
response = self.client.get(f"/api/v1/profiles/{self.institution.id}/") |
||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK) |
||||
|
self.assertEqual(len(response.data["quick_access_links"]), 2) |
||||
|
|
||||
|
def test_list_quick_access_links_public_and_admin(self): |
||||
|
# Public user |
||||
|
response = self.client.get(f"/api/v1/profiles/{self.institution.slug}/quick-links/") |
||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK) |
||||
|
self.assertEqual(len(response.data), 1) |
||||
|
self.assertEqual(response.data[0]["title"], self.link1.title) |
||||
|
|
||||
|
# Admin user |
||||
|
self.client.force_authenticate(user=self.admin_user) |
||||
|
response = self.client.get(f"/api/v1/profiles/{self.institution.id}/quick-links/") |
||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK) |
||||
|
self.assertEqual(len(response.data), 2) |
||||
|
|
||||
|
def test_create_quick_access_link_permission(self): |
||||
|
new_data = { |
||||
|
"title": "کتابخانه دیجیتال", |
||||
|
"url": "https://digital.razavi.ir", |
||||
|
"description": "دسترسی به کتب", |
||||
|
"icon": "bookOpen", |
||||
|
"color": "blue", |
||||
|
"category": "کتابخانه", |
||||
|
"order": 3, |
||||
|
"is_live": False, |
||||
|
"is_external": True, |
||||
|
} |
||||
|
|
||||
|
# Unauthenticated create should fail (401) |
||||
|
response = self.client.post(f"/api/v1/profiles/{self.institution.id}/quick-links/", new_data, format="json") |
||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) |
||||
|
|
||||
|
# Unauthorized user create should fail (403) |
||||
|
self.client.force_authenticate(user=self.other_user) |
||||
|
response = self.client.post(f"/api/v1/profiles/{self.institution.id}/quick-links/", new_data, format="json") |
||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) |
||||
|
|
||||
|
# Admin create should succeed (201) |
||||
|
self.client.force_authenticate(user=self.admin_user) |
||||
|
response = self.client.post(f"/api/v1/profiles/{self.institution.slug}/quick-links/", new_data, format="json") |
||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED) |
||||
|
self.assertEqual(response.data["title"], "کتابخانه دیجیتال") |
||||
|
self.assertEqual(response.data["institution_id"], self.institution.id) |
||||
|
|
||||
|
def test_url_validation(self): |
||||
|
self.client.force_authenticate(user=self.admin_user) |
||||
|
invalid_data = { |
||||
|
"title": "Invalid Link", |
||||
|
"url": "javascript:alert('xss')", |
||||
|
} |
||||
|
response = self.client.post(f"/api/v1/profiles/{self.institution.id}/quick-links/", invalid_data, 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 isinstance(response.data, dict) and 'errors' in response.data else response.data |
||||
|
self.assertIn("url", error_fields) |
||||
|
|
||||
|
def test_retrieve_update_and_delete_quick_link(self): |
||||
|
# Retrieve detail |
||||
|
response = self.client.get(f"/api/v1/profiles/{self.institution.id}/quick-links/{self.link1.id}/") |
||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK) |
||||
|
self.assertEqual(response.data["id"], self.link1.id) |
||||
|
|
||||
|
# Update link (patch) |
||||
|
self.client.force_authenticate(user=self.admin_user) |
||||
|
patch_response = self.client.patch( |
||||
|
f"/api/v1/profiles/{self.institution.id}/quick-links/{self.link1.id}/", |
||||
|
{"title": "تور مجازی ۳۶۰ درجه به روز رسانی شده"}, |
||||
|
format="json", |
||||
|
) |
||||
|
self.assertEqual(patch_response.status_code, status.HTTP_200_OK) |
||||
|
self.assertEqual(patch_response.data["title"], "تور مجازی ۳۶۰ درجه به روز رسانی شده") |
||||
|
|
||||
|
# Delete link |
||||
|
del_response = self.client.delete(f"/api/v1/profiles/{self.institution.id}/quick-links/{self.link1.id}/") |
||||
|
self.assertEqual(del_response.status_code, status.HTTP_204_NO_CONTENT) |
||||
|
self.assertFalse(QuickAccessLink.objects.filter(id=self.link1.id).exists()) |
||||
|
|
||||
|
def test_reorder_quick_links(self): |
||||
|
self.client.force_authenticate(user=self.admin_user) |
||||
|
reorder_data = {"order": [self.link2.id, self.link1.id]} |
||||
|
response = self.client.post( |
||||
|
f"/api/v1/profiles/{self.institution.id}/quick-links/reorder/", |
||||
|
reorder_data, |
||||
|
format="json", |
||||
|
) |
||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK) |
||||
|
self.link1.refresh_from_db() |
||||
|
self.link2.refresh_from_db() |
||||
|
self.assertEqual(self.link2.order, 0) |
||||
|
self.assertEqual(self.link1.order, 1) |
||||
15588
openapi.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
1850
schema.yml
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue