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.
- apps/__init__.py +1 -0
- apps/admissions/__init__.py +0 -0
- apps/admissions/admin.py +28 -0
- apps/admissions/apps.py +6 -0
- apps/admissions/migrations/0001_initial.py +80 -0
- apps/admissions/migrations/__init__.py +0 -0
- apps/admissions/models.py +105 -0
- apps/admissions/serializers.py +28 -0
- apps/admissions/tests.py +29 -0
- apps/admissions/urls.py +16 -0
- apps/admissions/views.py +30 -0
- apps/core/__init__.py +0 -0
- apps/core/admin.py +17 -0
- apps/core/apps.py +6 -0
- apps/core/cli.py +1341 -0
- apps/core/compliance.py +236 -0
- apps/core/management/__init__.py +0 -0
- apps/core/management/commands/__init__.py +0 -0
- apps/core/management/commands/seed_school_data.py +281 -0
- apps/core/management/commands/wait_for_db.py +26 -0
- apps/core/migrations/0001_initial.py +55 -0
- apps/core/migrations/0002_alter_schoolprofile_options.py +17 -0
- apps/core/migrations/__init__.py +0 -0
- apps/core/models.py +134 -0
- apps/core/serializers.py +18 -0
- apps/core/templatetags/__init__.py +1 -0
- apps/core/templatetags/compliance_tags.py +13 -0
- apps/core/tests.py +123 -0
- apps/core/urls.py +14 -0
- apps/core/views.py +130 -0
- apps/gallery/__init__.py +0 -0
- apps/gallery/admin.py +18 -0
- apps/gallery/apps.py +6 -0
- apps/gallery/migrations/0001_initial.py +46 -0
- apps/gallery/migrations/__init__.py +0 -0
- apps/gallery/models.py +48 -0
- apps/gallery/serializers.py +14 -0
- apps/gallery/tests.py +31 -0
- apps/gallery/urls.py +11 -0
- apps/gallery/views.py +15 -0
- apps/legal/__init__.py +0 -0
- apps/legal/admin.py +31 -0
- apps/legal/apps.py +6 -0
- apps/legal/migrations/0001_initial.py +105 -0
- apps/legal/migrations/__init__.py +0 -0
- apps/legal/models.py +172 -0
- apps/legal/serializers.py +43 -0
- apps/legal/tests.py +42 -0
- apps/legal/urls.py +17 -0
- apps/legal/views.py +33 -0
- apps/news/__init__.py +0 -0
- apps/news/admin.py +13 -0
- apps/news/apps.py +6 -0
- apps/news/migrations/0001_initial.py +46 -0
- apps/news/migrations/__init__.py +0 -0
- apps/news/models.py +55 -0
- apps/news/serializers.py +14 -0
- apps/news/tests.py +37 -0
- apps/news/urls.py +11 -0
- apps/news/views.py +15 -0
- apps/nutrition/__init__.py +0 -0
- apps/nutrition/admin.py +12 -0
- apps/nutrition/apps.py +6 -0
- apps/nutrition/migrations/0001_initial.py +47 -0
- apps/nutrition/migrations/0002_alter_cateringprovider_options.py +17 -0
- apps/nutrition/migrations/__init__.py +0 -0
- apps/nutrition/models.py +53 -0
- apps/nutrition/serializers.py +17 -0
- apps/nutrition/tests.py +41 -0
- apps/nutrition/urls.py +13 -0
- apps/nutrition/views.py +13 -0
- apps/safety/__init__.py +0 -0
- apps/safety/admin.py +21 -0
- apps/safety/apps.py +6 -0
- apps/safety/migrations/0001_initial.py +48 -0
- apps/safety/migrations/__init__.py +0 -0
- apps/safety/models.py +70 -0
- apps/safety/serializers.py +17 -0
- apps/safety/tests.py +20 -0
- apps/safety/urls.py +11 -0
- apps/safety/views.py +17 -0
- apps/schedule/__init__.py +0 -0
- apps/schedule/admin.py +12 -0
- apps/schedule/apps.py +6 -0
- apps/schedule/migrations/0001_initial.py +49 -0
- apps/schedule/migrations/__init__.py +0 -0
- apps/schedule/models.py +60 -0
- apps/schedule/serializers.py +15 -0
- apps/schedule/tests.py +51 -0
- apps/schedule/urls.py +11 -0
- apps/schedule/views.py +15 -0
- django_school_framework-0.2.2.dist-info/METADATA +53 -0
- django_school_framework-0.2.2.dist-info/RECORD +96 -0
- django_school_framework-0.2.2.dist-info/WHEEL +5 -0
- django_school_framework-0.2.2.dist-info/entry_points.txt +2 -0
- 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}"
|
apps/core/serializers.py
ADDED
|
@@ -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
|
+
|
apps/gallery/__init__.py
ADDED
|
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,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
|