django-school-framework 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. apps/__init__.py +1 -0
  2. apps/admissions/__init__.py +0 -0
  3. apps/admissions/admin.py +28 -0
  4. apps/admissions/apps.py +6 -0
  5. apps/admissions/migrations/0001_initial.py +80 -0
  6. apps/admissions/migrations/__init__.py +0 -0
  7. apps/admissions/models.py +105 -0
  8. apps/admissions/serializers.py +28 -0
  9. apps/admissions/tests.py +29 -0
  10. apps/admissions/urls.py +16 -0
  11. apps/admissions/views.py +30 -0
  12. apps/core/__init__.py +0 -0
  13. apps/core/admin.py +17 -0
  14. apps/core/apps.py +6 -0
  15. apps/core/cli.py +1341 -0
  16. apps/core/compliance.py +236 -0
  17. apps/core/management/__init__.py +0 -0
  18. apps/core/management/commands/__init__.py +0 -0
  19. apps/core/management/commands/seed_school_data.py +281 -0
  20. apps/core/management/commands/wait_for_db.py +26 -0
  21. apps/core/migrations/0001_initial.py +55 -0
  22. apps/core/migrations/0002_alter_schoolprofile_options.py +17 -0
  23. apps/core/migrations/__init__.py +0 -0
  24. apps/core/models.py +134 -0
  25. apps/core/serializers.py +18 -0
  26. apps/core/templatetags/__init__.py +1 -0
  27. apps/core/templatetags/compliance_tags.py +13 -0
  28. apps/core/tests.py +123 -0
  29. apps/core/urls.py +14 -0
  30. apps/core/views.py +130 -0
  31. apps/gallery/__init__.py +0 -0
  32. apps/gallery/admin.py +18 -0
  33. apps/gallery/apps.py +6 -0
  34. apps/gallery/migrations/0001_initial.py +46 -0
  35. apps/gallery/migrations/__init__.py +0 -0
  36. apps/gallery/models.py +48 -0
  37. apps/gallery/serializers.py +14 -0
  38. apps/gallery/tests.py +31 -0
  39. apps/gallery/urls.py +11 -0
  40. apps/gallery/views.py +15 -0
  41. apps/legal/__init__.py +0 -0
  42. apps/legal/admin.py +31 -0
  43. apps/legal/apps.py +6 -0
  44. apps/legal/migrations/0001_initial.py +105 -0
  45. apps/legal/migrations/__init__.py +0 -0
  46. apps/legal/models.py +172 -0
  47. apps/legal/serializers.py +43 -0
  48. apps/legal/tests.py +42 -0
  49. apps/legal/urls.py +17 -0
  50. apps/legal/views.py +33 -0
  51. apps/news/__init__.py +0 -0
  52. apps/news/admin.py +13 -0
  53. apps/news/apps.py +6 -0
  54. apps/news/migrations/0001_initial.py +46 -0
  55. apps/news/migrations/__init__.py +0 -0
  56. apps/news/models.py +55 -0
  57. apps/news/serializers.py +14 -0
  58. apps/news/tests.py +37 -0
  59. apps/news/urls.py +11 -0
  60. apps/news/views.py +15 -0
  61. apps/nutrition/__init__.py +0 -0
  62. apps/nutrition/admin.py +12 -0
  63. apps/nutrition/apps.py +6 -0
  64. apps/nutrition/migrations/0001_initial.py +47 -0
  65. apps/nutrition/migrations/0002_alter_cateringprovider_options.py +17 -0
  66. apps/nutrition/migrations/__init__.py +0 -0
  67. apps/nutrition/models.py +53 -0
  68. apps/nutrition/serializers.py +17 -0
  69. apps/nutrition/tests.py +41 -0
  70. apps/nutrition/urls.py +13 -0
  71. apps/nutrition/views.py +13 -0
  72. apps/safety/__init__.py +0 -0
  73. apps/safety/admin.py +21 -0
  74. apps/safety/apps.py +6 -0
  75. apps/safety/migrations/0001_initial.py +48 -0
  76. apps/safety/migrations/__init__.py +0 -0
  77. apps/safety/models.py +70 -0
  78. apps/safety/serializers.py +17 -0
  79. apps/safety/tests.py +20 -0
  80. apps/safety/urls.py +11 -0
  81. apps/safety/views.py +17 -0
  82. apps/schedule/__init__.py +0 -0
  83. apps/schedule/admin.py +12 -0
  84. apps/schedule/apps.py +6 -0
  85. apps/schedule/migrations/0001_initial.py +49 -0
  86. apps/schedule/migrations/__init__.py +0 -0
  87. apps/schedule/models.py +60 -0
  88. apps/schedule/serializers.py +15 -0
  89. apps/schedule/tests.py +51 -0
  90. apps/schedule/urls.py +11 -0
  91. apps/schedule/views.py +15 -0
  92. django_school_framework-0.2.2.dist-info/METADATA +53 -0
  93. django_school_framework-0.2.2.dist-info/RECORD +96 -0
  94. django_school_framework-0.2.2.dist-info/WHEEL +5 -0
  95. django_school_framework-0.2.2.dist-info/entry_points.txt +2 -0
  96. django_school_framework-0.2.2.dist-info/top_level.txt +1 -0
apps/core/models.py ADDED
@@ -0,0 +1,134 @@
1
+ from django.db import models
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+ class AbstractSchoolProfile(models.Model):
5
+ """
6
+ Абстрактна базова модель профілю закладу загальної середньої освіти.
7
+ Розробники можуть наслідувати цей клас для додавання специфічних полів (наприклад, типи спеціалізацій, філії).
8
+ """
9
+ name = models.CharField(
10
+ max_length=255,
11
+ verbose_name=_('Повна назва закладу освіти'),
12
+ help_text=_('Наприклад: Ліцей "Інтелект та Інновації" Київської міської ради')
13
+ )
14
+ short_name = models.CharField(
15
+ max_length=100,
16
+ blank=True,
17
+ verbose_name=_('Скорочена назва')
18
+ )
19
+ edrpou = models.CharField(
20
+ max_length=10,
21
+ unique=True,
22
+ verbose_name=_('Код ЄДРПОУ')
23
+ )
24
+ founder = models.CharField(
25
+ max_length=255,
26
+ verbose_name=_('Засновник закладу освіти'),
27
+ help_text=_('Орган державної влади, відповідна рада територіальної громади або уповноважена особа')
28
+ )
29
+ address = models.CharField(
30
+ max_length=255,
31
+ verbose_name=_('Місцезнаходження (юридична адреса)')
32
+ )
33
+ phone = models.CharField(
34
+ max_length=50,
35
+ verbose_name=_('Контактний телефон')
36
+ )
37
+ email = models.EmailField(
38
+ verbose_name=_('Офіційна електронна адреса')
39
+ )
40
+ website = models.URLField(
41
+ blank=True,
42
+ verbose_name=_('Адреса веб-сайту')
43
+ )
44
+ licensed_capacity = models.PositiveIntegerField(
45
+ default=0,
46
+ verbose_name=_('Ліцензований обсяг (кількість учнів)')
47
+ )
48
+ actual_students = models.PositiveIntegerField(
49
+ default=0,
50
+ verbose_name=_('Фактична кількість здобувачів освіти')
51
+ )
52
+ education_language = models.CharField(
53
+ max_length=100,
54
+ default='Українська',
55
+ verbose_name=_('Мова (мови) освітнього процесу')
56
+ )
57
+ logo = models.ImageField(
58
+ upload_to='school/logo/',
59
+ blank=True,
60
+ null=True,
61
+ verbose_name=_('Логотип / герб закладу')
62
+ )
63
+ created_at = models.DateTimeField(auto_now_add=True)
64
+ updated_at = models.DateTimeField(auto_now=True)
65
+
66
+ class Meta:
67
+ abstract = True
68
+ verbose_name = _('Профіль закладу освіти')
69
+ verbose_name_plural = _('Профіль закладу освіти')
70
+ ordering = ['id']
71
+
72
+ def __str__(self):
73
+ return f"{self.name} (ЄДРПОУ {self.edrpou})"
74
+
75
+
76
+ class SchoolProfile(AbstractSchoolProfile):
77
+ """Стандартна реалізація профілю школи за замовчуванням."""
78
+ class Meta(AbstractSchoolProfile.Meta):
79
+ abstract = False
80
+
81
+
82
+ class AbstractThemeSettings(models.Model):
83
+ """
84
+ Абстрактна базова модель налаштувань теми та колірного оформлення.
85
+ """
86
+ primary_color = models.CharField(
87
+ max_length=20,
88
+ default='#1e3a8a',
89
+ verbose_name=_('Основний колір (Primary Hex)')
90
+ )
91
+ primary_hover = models.CharField(
92
+ max_length=20,
93
+ default='#172554',
94
+ verbose_name=_('Колір при наведенні (Primary Hover Hex)')
95
+ )
96
+ secondary_color = models.CharField(
97
+ max_length=20,
98
+ default='#059669',
99
+ verbose_name=_('Додатковий колір (Secondary Hex)')
100
+ )
101
+ accent_color = models.CharField(
102
+ max_length=20,
103
+ default='#d97706',
104
+ verbose_name=_('Акцентний колір (Accent Hex)')
105
+ )
106
+ font_family = models.CharField(
107
+ max_length=100,
108
+ default='Inter, system-ui, sans-serif',
109
+ verbose_name=_('Основний шрифт')
110
+ )
111
+
112
+ class Meta:
113
+ abstract = True
114
+ verbose_name = _('Налаштування теми та брендингу')
115
+ verbose_name_plural = _('Налаштування тем та брендингу')
116
+
117
+ def __str__(self):
118
+ return f"Тема для закладу освіти"
119
+
120
+
121
+ class ThemeSettings(AbstractThemeSettings):
122
+ """Стандартна реалізація теми, прив'язана до SchoolProfile."""
123
+ school = models.OneToOneField(
124
+ SchoolProfile,
125
+ on_delete=models.CASCADE,
126
+ related_name='theme',
127
+ verbose_name=_('Заклад освіти')
128
+ )
129
+
130
+ class Meta(AbstractThemeSettings.Meta):
131
+ abstract = False
132
+
133
+ def __str__(self):
134
+ return f"Тема для {self.school.name}"
@@ -0,0 +1,18 @@
1
+ from rest_framework import serializers
2
+ from .models import SchoolProfile, ThemeSettings
3
+
4
+ class ThemeSettingsSerializer(serializers.ModelSerializer):
5
+ class Meta:
6
+ model = ThemeSettings
7
+ fields = ['primary_color', 'primary_hover', 'secondary_color', 'accent_color', 'font_family']
8
+
9
+ class SchoolProfileSerializer(serializers.ModelSerializer):
10
+ theme = ThemeSettingsSerializer(read_only=True)
11
+
12
+ class Meta:
13
+ model = SchoolProfile
14
+ fields = [
15
+ 'id', 'name', 'short_name', 'edrpou', 'founder', 'address',
16
+ 'phone', 'email', 'website', 'licensed_capacity', 'actual_students',
17
+ 'education_language', 'logo', 'theme', 'updated_at'
18
+ ]
@@ -0,0 +1 @@
1
+ # Template tags for core app
@@ -0,0 +1,13 @@
1
+ from django import template
2
+ from apps.core.compliance import ComplianceAuditor
3
+
4
+ register = template.Library()
5
+
6
+
7
+ @register.simple_tag
8
+ def get_compliance_report():
9
+ """
10
+ Повертає аудит-звіт відповідності закладу освіти вимогам
11
+ ст. 30 ЗУ «Про освіту» та суміжного законодавства.
12
+ """
13
+ return ComplianceAuditor.get_audit_report()
apps/core/tests.py ADDED
@@ -0,0 +1,123 @@
1
+ from pathlib import Path
2
+ from django.test import TestCase
3
+ from rest_framework.test import APIClient
4
+ from rest_framework import status
5
+ from .models import SchoolProfile, ThemeSettings
6
+
7
+ class CoreModelAndApiTests(TestCase):
8
+ def setUp(self):
9
+ self.client = APIClient()
10
+ self.profile = SchoolProfile.objects.create(
11
+ name='Ліцей №1 м. Києва',
12
+ short_name='Ліцей №1',
13
+ edrpou='12345678',
14
+ founder='Київська міська рада',
15
+ address='вул. Хрещатик, 1, м. Київ',
16
+ phone='+380 44 111-22-33',
17
+ email='school1@kyiv.edu.ua',
18
+ licensed_capacity=1000,
19
+ actual_students=950,
20
+ education_language='Українська'
21
+ )
22
+ self.theme = ThemeSettings.objects.create(
23
+ school=self.profile,
24
+ primary_color='#1e3a8a',
25
+ primary_hover='#172554',
26
+ secondary_color='#059669',
27
+ accent_color='#d97706'
28
+ )
29
+
30
+ def test_school_profile_str(self):
31
+ self.assertEqual(str(self.profile), 'Ліцей №1 м. Києва (ЄДРПОУ 12345678)')
32
+
33
+ def test_theme_settings_str(self):
34
+ self.assertIn('Ліцей №1 м. Києва', str(self.theme))
35
+
36
+ def test_profile_api_read_only(self):
37
+ res = self.client.get('/api/v1/core/profile/')
38
+ self.assertEqual(res.status_code, status.HTTP_200_OK)
39
+ data = res.json()
40
+ results = data.get('results', data)
41
+ self.assertTrue(len(results) > 0)
42
+ item = results[0]
43
+ self.assertEqual(item['edrpou'], '12345678')
44
+ self.assertIn('theme', item)
45
+ self.assertEqual(item['theme']['primary_color'], '#1e3a8a')
46
+
47
+ def test_manifest_capabilities_api(self):
48
+ res = self.client.get('/api/v1/core/manifest/')
49
+ self.assertEqual(res.status_code, status.HTTP_200_OK)
50
+ data = res.json()
51
+ self.assertIn('school', data)
52
+ self.assertEqual(data['school']['name'], 'Ліцей №1 м. Києва')
53
+ self.assertIn('modules', data)
54
+ self.assertTrue(data['modules']['legal'])
55
+ self.assertIn('navigation', data)
56
+ self.assertTrue(len(data['navigation']) > 0)
57
+ self.assertIn('compliance', data)
58
+ self.assertTrue(data['compliance']['article_30'])
59
+
60
+ def test_compliance_auditor_and_api(self):
61
+ from .compliance import ComplianceAuditor
62
+ report = ComplianceAuditor.get_audit_report()
63
+ self.assertIn('score', report)
64
+ self.assertIn('checks', report)
65
+ self.assertIn('warnings', report)
66
+ self.assertTrue(report['total_count'] > 0)
67
+
68
+ # Test API endpoint
69
+ res = self.client.get('/api/v1/core/compliance/')
70
+ self.assertEqual(res.status_code, status.HTTP_200_OK)
71
+ data = res.json()
72
+ self.assertEqual(data['score'], report['score'])
73
+ self.assertEqual(data['total_count'], report['total_count'])
74
+
75
+ def test_admin_index_renders_compliance_dashboard(self):
76
+ from django.contrib.auth.models import User
77
+ admin_user = User.objects.create_superuser(username='admin_test', password='password123', email='admin@test.com')
78
+ self.client.force_login(admin_user)
79
+ res = self.client.get('/admin/')
80
+ self.assertEqual(res.status_code, 200)
81
+ content = res.content.decode('utf-8')
82
+ self.assertIn('Монітор виконання ст. 30 ЗУ «Про освіту»', content)
83
+ self.assertIn('Рівень відповідності', content)
84
+
85
+ def test_cli_startproject_and_school_creation(self):
86
+ import tempfile
87
+ import shutil
88
+ from argparse import Namespace
89
+ from .cli import cmd_startproject, cmd_create_school
90
+
91
+ temp_dir = tempfile.mkdtemp()
92
+ try:
93
+ # Test startproject
94
+ args_start = Namespace(name='test_school_cli', output_dir=temp_dir)
95
+ cmd_startproject(args_start)
96
+ project_path = Path(temp_dir) / 'test_school_cli'
97
+ self.assertTrue((project_path / 'manage.py').exists())
98
+ self.assertTrue((project_path / 'config' / 'settings.py').exists())
99
+
100
+ # Test create-school
101
+ args_create = Namespace(
102
+ name='Нова Гімназія №12',
103
+ short_name='Гімназія №12',
104
+ edrpou='99887766',
105
+ founder='Міська Рада',
106
+ city='Львів',
107
+ address='вул. Франка, 10',
108
+ phone='+380 32 200-00-00',
109
+ email='gymn12@lviv.ua',
110
+ capacity=800,
111
+ actual=750,
112
+ language='Українська',
113
+ primary_color='#2563eb'
114
+ )
115
+ cmd_create_school(args_create)
116
+ school = SchoolProfile.objects.get(edrpou='99887766')
117
+ self.assertEqual(school.name, 'Нова Гімназія №12')
118
+ self.assertEqual(school.licensed_capacity, 800)
119
+ finally:
120
+ shutil.rmtree(temp_dir, ignore_errors=True)
121
+
122
+
123
+
apps/core/urls.py ADDED
@@ -0,0 +1,14 @@
1
+ from django.urls import path, include
2
+ from rest_framework.routers import DefaultRouter
3
+ from .views import SchoolProfileViewSet, ThemeSettingsViewSet, SchoolManifestView, ComplianceAuditView
4
+
5
+ router = DefaultRouter()
6
+ router.register(r'profile', SchoolProfileViewSet, basename='school-profile')
7
+ router.register(r'theme', ThemeSettingsViewSet, basename='school-theme')
8
+
9
+ urlpatterns = [
10
+ path('manifest/', SchoolManifestView.as_view(), name='school-manifest'),
11
+ path('capabilities/', SchoolManifestView.as_view(), name='school-capabilities'),
12
+ path('compliance/', ComplianceAuditView.as_view(), name='school-compliance'),
13
+ path('', include(router.urls)),
14
+ ]
apps/core/views.py ADDED
@@ -0,0 +1,130 @@
1
+ from rest_framework import viewsets, permissions
2
+ from rest_framework.views import APIView
3
+ from rest_framework.response import Response
4
+ from django.apps import apps
5
+ from .models import SchoolProfile, ThemeSettings
6
+ from .serializers import SchoolProfileSerializer, ThemeSettingsSerializer
7
+
8
+ class SchoolProfileViewSet(viewsets.ReadOnlyModelViewSet):
9
+ queryset = SchoolProfile.objects.all().select_related('theme')
10
+ serializer_class = SchoolProfileSerializer
11
+ permission_classes = [permissions.AllowAny]
12
+
13
+
14
+ class ThemeSettingsViewSet(viewsets.ReadOnlyModelViewSet):
15
+ queryset = ThemeSettings.objects.all()
16
+ serializer_class = ThemeSettingsSerializer
17
+ permission_classes = [permissions.AllowAny]
18
+
19
+
20
+ class SchoolManifestView(APIView):
21
+ """
22
+ Системний маніфест закладу освіти для фреймворку:
23
+ Повертає профіль школи, динамічний перелік активних модулів та меню навігації.
24
+ """
25
+ permission_classes = [permissions.AllowAny]
26
+
27
+ def get(self, request, *args, **kwargs):
28
+ profile = SchoolProfile.objects.select_related('theme').first()
29
+ school_data = SchoolProfileSerializer(profile).data if profile else {
30
+ "name": "Офіційний вебсайт закладу освіти",
31
+ "short_name": "Заклад освіти",
32
+ "edrpou": "",
33
+ "founder": "Орган місцевого самоврядування",
34
+ "address": "",
35
+ "phone": "",
36
+ "email": "",
37
+ "website": "",
38
+ "licensed_capacity": 0,
39
+ "actual_students": 0,
40
+ "education_language": "Українська",
41
+ "logo": None,
42
+ "theme": {
43
+ "primary_color": "#1e3a8a",
44
+ "primary_hover": "#172554",
45
+ "secondary_color": "#059669",
46
+ "accent_color": "#d97706",
47
+ "font_family": "Inter, system-ui, sans-serif"
48
+ }
49
+ }
50
+
51
+ # Dynamic module detection based on INSTALLED_APPS
52
+ has_legal = apps.is_installed('apps.legal')
53
+ has_admissions = apps.is_installed('apps.admissions')
54
+ has_nutrition = apps.is_installed('apps.nutrition')
55
+ has_safety = apps.is_installed('apps.safety')
56
+ has_news = apps.is_installed('apps.news')
57
+ has_schedule = apps.is_installed('apps.schedule')
58
+ has_gallery = apps.is_installed('apps.gallery')
59
+
60
+ modules = {
61
+ 'legal': has_legal,
62
+ 'admissions': has_admissions,
63
+ 'nutrition': has_nutrition,
64
+ 'safety': has_safety,
65
+ 'news': has_news,
66
+ 'schedule': has_schedule,
67
+ 'gallery': has_gallery,
68
+ }
69
+
70
+ navigation = []
71
+ if has_legal:
72
+ navigation.append({'id': 'legal', 'title': 'Прозорість (ст. 30)', 'icon': 'FileCheck'})
73
+ navigation.append({'id': 'finance', 'title': 'Кошторис та звітність', 'icon': 'DollarSign'})
74
+ if has_admissions:
75
+ navigation.append({'id': 'admissions', 'title': 'Вступ та зарахування', 'icon': 'Users'})
76
+ if has_schedule:
77
+ navigation.append({'id': 'schedule', 'title': 'Розклад', 'icon': 'Calendar'})
78
+ if has_nutrition:
79
+ navigation.append({'id': 'nutrition', 'title': 'Харчування', 'icon': 'Utensils'})
80
+ if has_safety:
81
+ navigation.append({'id': 'safety', 'title': 'Безпека та булінг', 'icon': 'ShieldAlert'})
82
+ if has_news:
83
+ navigation.append({'id': 'news', 'title': 'Новини', 'icon': 'Newspaper'})
84
+ if has_gallery:
85
+ navigation.append({'id': 'gallery', 'title': 'Галерея', 'icon': 'Image'})
86
+
87
+ return Response({
88
+ 'school': school_data,
89
+ 'modules': modules,
90
+ 'navigation': navigation,
91
+ 'compliance': {
92
+ 'article_30': True,
93
+ 'wcag_version': '2.1 AA',
94
+ 'privacy_law_enforced': True,
95
+ 'financial_deadline_days': 10
96
+ }
97
+ })
98
+
99
+
100
+ from .compliance import ComplianceAuditor
101
+
102
+ class ComplianceAuditView(APIView):
103
+ """
104
+ API монітора інформаційної відкритості закладу освіти:
105
+ Повертає деталізований звіт відповідності вимогам ст. 30 ЗУ «Про освіту»,
106
+ відсоток виконання та оперативні попередження щодо строків.
107
+ """
108
+ permission_classes = [permissions.AllowAny]
109
+
110
+ def get(self, request, *args, **kwargs):
111
+ report = ComplianceAuditor.get_audit_report()
112
+ return Response(report)
113
+
114
+
115
+ from django.views.generic import TemplateView
116
+
117
+ class PublicSchoolHomeView(TemplateView):
118
+ """
119
+ Публічна головна сторінка закладу освіти з підтримкою доступності WCAG 2.1 AA
120
+ та автоматичним відображенням статусу ст. 30 ЗУ «Про освіту».
121
+ """
122
+ template_name = 'school/home.html'
123
+
124
+ def get_context_data(self, **kwargs):
125
+ ctx = super().get_context_data(**kwargs)
126
+ ctx['school'] = SchoolProfile.objects.select_related('theme').first()
127
+ ctx['compliance'] = ComplianceAuditor.get_audit_report()
128
+ return ctx
129
+
130
+
File without changes
apps/gallery/admin.py ADDED
@@ -0,0 +1,18 @@
1
+ from django.contrib import admin
2
+ from .models import PhotoAlbum, GalleryPhoto
3
+
4
+ class GalleryPhotoInline(admin.TabularInline):
5
+ model = GalleryPhoto
6
+ extra = 3
7
+ fields = ('image', 'caption', 'order')
8
+
9
+ @admin.register(PhotoAlbum)
10
+ class PhotoAlbumAdmin(admin.ModelAdmin):
11
+ list_display = ('title', 'event_date', 'created_at')
12
+ search_fields = ('title', 'description')
13
+ inlines = [GalleryPhotoInline]
14
+
15
+ @admin.register(GalleryPhoto)
16
+ class GalleryPhotoAdmin(admin.ModelAdmin):
17
+ list_display = ('album', 'caption', 'order')
18
+ list_filter = ('album',)
apps/gallery/apps.py ADDED
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+ class GalleryConfig(AppConfig):
4
+ default_auto_field = 'django.db.models.BigAutoField'
5
+ name = 'apps.gallery'
6
+ verbose_name = 'Фото- та відеогалерея'
@@ -0,0 +1,46 @@
1
+ # Generated by Django 5.1.15 on 2026-10-15 06:10
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name='PhotoAlbum',
17
+ fields=[
18
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19
+ ('title', models.CharField(max_length=200, verbose_name='Назва альбому / події')),
20
+ ('description', models.TextField(blank=True, verbose_name='Опис події')),
21
+ ('cover_image', models.ImageField(blank=True, null=True, upload_to='gallery/covers/%Y/', verbose_name='Обкладинка альбому')),
22
+ ('event_date', models.DateField(verbose_name='Дата заходу')),
23
+ ('created_at', models.DateTimeField(auto_now_add=True)),
24
+ ],
25
+ options={
26
+ 'verbose_name': 'Фотоальбом',
27
+ 'verbose_name_plural': 'Фотоальбоми',
28
+ 'ordering': ['-event_date'],
29
+ },
30
+ ),
31
+ migrations.CreateModel(
32
+ name='GalleryPhoto',
33
+ fields=[
34
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
35
+ ('image', models.ImageField(upload_to='gallery/photos/%Y/%m/', verbose_name='Фотографія')),
36
+ ('caption', models.CharField(blank=True, max_length=255, verbose_name='Підпис до фото')),
37
+ ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок відображення')),
38
+ ('album', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='gallery.photoalbum', verbose_name='Альбом')),
39
+ ],
40
+ options={
41
+ 'verbose_name': 'Фотографія галереї',
42
+ 'verbose_name_plural': 'Фотографії галереї',
43
+ 'ordering': ['order', 'id'],
44
+ },
45
+ ),
46
+ ]
File without changes
apps/gallery/models.py ADDED
@@ -0,0 +1,48 @@
1
+ from django.db import models
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+ class AbstractPhotoAlbum(models.Model):
5
+ title = models.CharField(max_length=200, verbose_name=_('Назва альбому / події'))
6
+ description = models.TextField(blank=True, verbose_name=_('Опис події'))
7
+ cover_image = models.ImageField(upload_to='gallery/covers/%Y/', blank=True, null=True, verbose_name=_('Обкладинка альбому'))
8
+ event_date = models.DateField(verbose_name=_('Дата заходу'))
9
+ created_at = models.DateTimeField(auto_now_add=True)
10
+
11
+ class Meta:
12
+ abstract = True
13
+ verbose_name = _('Фотоальбом')
14
+ verbose_name_plural = _('Фотоальбоми')
15
+ ordering = ['-event_date']
16
+
17
+ def __str__(self):
18
+ return f"{self.title} ({self.event_date})"
19
+
20
+
21
+ class PhotoAlbum(AbstractPhotoAlbum):
22
+ class Meta(AbstractPhotoAlbum.Meta):
23
+ abstract = False
24
+
25
+
26
+ class AbstractGalleryPhoto(models.Model):
27
+ image = models.ImageField(upload_to='gallery/photos/%Y/%m/', verbose_name=_('Фотографія'))
28
+ caption = models.CharField(max_length=255, blank=True, verbose_name=_('Підпис до фото'))
29
+ order = models.PositiveIntegerField(default=0, verbose_name=_('Порядок відображення'))
30
+
31
+ class Meta:
32
+ abstract = True
33
+ verbose_name = _('Фотографія галереї')
34
+ verbose_name_plural = _('Фотографії галереї')
35
+ ordering = ['order', 'id']
36
+
37
+ def __str__(self):
38
+ return f"Фотографія #{self.id}"
39
+
40
+
41
+ class GalleryPhoto(AbstractGalleryPhoto):
42
+ album = models.ForeignKey(PhotoAlbum, on_delete=models.CASCADE, related_name='photos', verbose_name=_('Альбом'))
43
+
44
+ class Meta(AbstractGalleryPhoto.Meta):
45
+ abstract = False
46
+
47
+ def __str__(self):
48
+ return f"Фото в альбомі {self.album.title}"
@@ -0,0 +1,14 @@
1
+ from rest_framework import serializers
2
+ from .models import PhotoAlbum, GalleryPhoto
3
+
4
+ class GalleryPhotoSerializer(serializers.ModelSerializer):
5
+ class Meta:
6
+ model = GalleryPhoto
7
+ fields = ['id', 'image', 'caption', 'order']
8
+
9
+ class PhotoAlbumSerializer(serializers.ModelSerializer):
10
+ photos = GalleryPhotoSerializer(many=True, read_only=True)
11
+
12
+ class Meta:
13
+ model = PhotoAlbum
14
+ fields = ['id', 'title', 'description', 'cover_image', 'event_date', 'photos']
apps/gallery/tests.py ADDED
@@ -0,0 +1,31 @@
1
+ from datetime import date
2
+ from django.test import TestCase
3
+ from rest_framework.test import APIClient
4
+ from rest_framework import status
5
+ from .models import PhotoAlbum, GalleryPhoto
6
+
7
+ class GalleryTests(TestCase):
8
+ def setUp(self):
9
+ self.client = APIClient()
10
+ self.album = PhotoAlbum.objects.create(
11
+ title='Свято першого дзвоника',
12
+ description='Урочиста лінійка 1 вересня',
13
+ event_date=date(2026, 9, 1)
14
+ )
15
+ self.photo = GalleryPhoto.objects.create(
16
+ album=self.album,
17
+ caption='Першокласники з квітами',
18
+ order=1
19
+ )
20
+
21
+ def test_str_representations(self):
22
+ self.assertEqual(str(self.album), 'Свято першого дзвоника (2026-09-01)')
23
+ self.assertEqual(str(self.photo), 'Фото в альбомі Свято першого дзвоника')
24
+
25
+ def test_albums_api_list(self):
26
+ res = self.client.get('/api/v1/gallery/albums/')
27
+ self.assertEqual(res.status_code, status.HTTP_200_OK)
28
+ data = res.json()
29
+ results = data.get('results', data)
30
+ self.assertTrue(len(results) > 0)
31
+ self.assertEqual(results[0]['title'], 'Свято першого дзвоника')
apps/gallery/urls.py ADDED
@@ -0,0 +1,11 @@
1
+ from django.urls import path, include
2
+ from rest_framework.routers import DefaultRouter
3
+ from .views import PhotoAlbumViewSet, GalleryPhotoViewSet
4
+
5
+ router = DefaultRouter()
6
+ router.register(r'albums', PhotoAlbumViewSet, basename='gallery-albums')
7
+ router.register(r'photos', GalleryPhotoViewSet, basename='gallery-photos')
8
+
9
+ urlpatterns = [
10
+ path('', include(router.urls)),
11
+ ]
apps/gallery/views.py ADDED
@@ -0,0 +1,15 @@
1
+ from rest_framework import viewsets, permissions, filters
2
+ from .models import PhotoAlbum, GalleryPhoto
3
+ from .serializers import PhotoAlbumSerializer, GalleryPhotoSerializer
4
+
5
+ class PhotoAlbumViewSet(viewsets.ReadOnlyModelViewSet):
6
+ queryset = PhotoAlbum.objects.all().prefetch_related('photos')
7
+ serializer_class = PhotoAlbumSerializer
8
+ permission_classes = [permissions.AllowAny]
9
+ filter_backends = [filters.SearchFilter]
10
+ search_fields = ['title', 'description']
11
+
12
+ class GalleryPhotoViewSet(viewsets.ReadOnlyModelViewSet):
13
+ queryset = GalleryPhoto.objects.all()
14
+ serializer_class = GalleryPhotoSerializer
15
+ permission_classes = [permissions.AllowAny]
apps/legal/__init__.py ADDED
File without changes