Harden portal access and hiring, add RRO portal CRUD.

Remove the is_staff permission bridge, tighten intern credential handling with one-time PDFs and random seed passwords, and let staff manage cash registers from the portal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-10 08:54:41 +03:00
parent 56e2b1c8fe
commit 70e5a77b04
16 changed files with 829 additions and 116 deletions
+177
View File
@@ -132,3 +132,180 @@ class CashRegisterPortalTests(TestCase):
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'FN-PORTAL-1')
self.assertContains(resp, 'ph-panel-rro')
def test_pharmacy_detail_hides_rro_for_other_pharmacy_user(self):
other = Pharmacy.objects.create(
organization=self.pharmacy.organization,
location_region=self.pharmacy.location_region,
pharmacy_number=99, location_type='pharmacy',
street='Чужа', house_number='9',
)
stranger = Employee.objects.create(
username='rro.stranger',
email='rro.stranger@company.local',
first_name='Інша',
last_name='Аптека',
hire_date=date(2026, 1, 1),
user_location_type='pharmacy',
user_pharmacy=other,
is_staff=False,
mattermost_sync_enabled=False,
ad_sync_enabled=False,
)
self.client.force_login(stranger)
session = self.client.session
session['2fa_verified'] = True
session.save()
resp = self.client.get(f'/pharmacies/{self.pharmacy.pk}/', HTTP_HX_REQUEST='true')
self.assertEqual(resp.status_code, 200)
self.assertNotContains(resp, 'FN-PORTAL-1')
self.assertNotContains(resp, 'ph-panel-rro')
self.assertFalse(resp.context['can_view_ops'])
self.assertEqual(resp.context['staff'], [])
self.assertEqual(resp.context['equipment'], [])
self.assertEqual(resp.context['internet_contracts'], [])
self.assertEqual(resp.context['utility_readings'], [])
self.assertNotContains(resp, 'ph-panel-equipment')
self.assertNotContains(resp, 'ph-panel-internet')
self.assertNotContains(resp, 'ph-panel-utilities')
class CashRegisterCrudTests(TestCase):
@classmethod
def setUpTestData(cls):
org_type = DirectoryType.objects.create(code='organization', name='Организация')
region_type = DirectoryType.objects.create(code='region', name='Регион')
status_type, _ = DirectoryType.objects.get_or_create(
code='rro_status', defaults={'name': 'Статус РРО/ПРРО', 'hierarchy_type': 'strict'},
)
cls.status_active, _ = Directory.objects.get_or_create(
directory_type=status_type, code='active',
defaults={'name': 'Активный', 'is_active': True},
)
cls.status_repair, _ = Directory.objects.get_or_create(
directory_type=status_type, code='repair',
defaults={'name': 'В ремонте', 'is_active': True},
)
region = Directory.objects.create(directory_type=region_type, code='od2', name='Одесса2')
org = Directory.objects.create(directory_type=org_type, code='org2', name='Орг2')
cls.pharmacy = Pharmacy.objects.create(
organization=org, location_region=region,
pharmacy_number=11, location_type='pharmacy',
street='CRUD', house_number='1',
)
cls.staff = Employee.objects.create(
username='rro.crud.staff',
email='rro.crud.staff@company.local',
first_name='CRUD',
last_name='Staff',
hire_date=date(2026, 1, 1),
user_location_type='office',
is_staff=True,
mattermost_sync_enabled=False,
ad_sync_enabled=False,
totp_enabled=True,
)
cls.regular = Employee.objects.create(
username='rro.crud.user',
email='rro.crud.user@company.local',
first_name='CRUD',
last_name='User',
hire_date=date(2026, 1, 1),
user_location_type='pharmacy',
user_pharmacy=cls.pharmacy,
is_staff=False,
mattermost_sync_enabled=False,
ad_sync_enabled=False,
)
def _login(self, user):
self.client.force_login(user)
session = self.client.session
session['2fa_verified'] = True
session.save()
def _payload(self, **extra):
data = {
'pharmacy': self.pharmacy.pk,
'kind': CashRegister.Kind.PRRO,
'fiscal_number': 'FN-CRUD-NEW',
'status': self.status_active.pk,
'model_name': 'Checkbox',
'provider': 'checkbox',
}
data.update(extra)
return data
def test_staff_create(self):
self._login(self.staff)
resp = self.client.post('/rro/create/', self._payload(), HTTP_HX_REQUEST='true')
self.assertEqual(resp.status_code, 200)
self.assertTrue(CashRegister.objects.filter(fiscal_number='FN-CRUD-NEW').exists())
self.assertContains(resp, 'Кассовый аппарат добавлен')
def test_non_staff_create_forbidden(self):
self._login(self.regular)
resp = self.client.post('/rro/create/', self._payload(fiscal_number='FN-FORBIDDEN'))
self.assertEqual(resp.status_code, 403)
self.assertFalse(CashRegister.objects.filter(fiscal_number='FN-FORBIDDEN').exists())
def test_staff_update(self):
reg = CashRegister.objects.create(
pharmacy=self.pharmacy, kind=CashRegister.Kind.RRO,
fiscal_number='FN-UPD', status=self.status_active,
)
self._login(self.staff)
resp = self.client.post(
f'/rro/{reg.pk}/update/',
self._payload(
fiscal_number='FN-UPD-2',
kind=CashRegister.Kind.RRO,
status=self.status_repair.pk,
provider='',
),
HTTP_HX_REQUEST='true',
)
self.assertEqual(resp.status_code, 200)
reg.refresh_from_db()
self.assertEqual(reg.fiscal_number, 'FN-UPD-2')
self.assertEqual(reg.status_id, self.status_repair.pk)
def test_duplicate_fiscal_shows_form_error(self):
CashRegister.objects.create(
pharmacy=self.pharmacy, kind=CashRegister.Kind.PRRO,
fiscal_number='FN-DUP', status=self.status_active,
)
self._login(self.staff)
resp = self.client.post(
'/rro/create/',
self._payload(fiscal_number='FN-DUP'),
HTTP_HX_REQUEST='true',
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(CashRegister.objects.filter(fiscal_number='FN-DUP').count(), 1)
self.assertTrue(resp.context['rro_form_errors'])
def test_primary_conflict_shows_form_error(self):
CashRegister.objects.create(
pharmacy=self.pharmacy, kind=CashRegister.Kind.PRRO,
fiscal_number='FN-PRIM-1', status=self.status_active, is_primary=True,
)
self._login(self.staff)
resp = self.client.post(
'/rro/create/',
self._payload(fiscal_number='FN-PRIM-2', is_primary='1'),
HTTP_HX_REQUEST='true',
)
self.assertEqual(resp.status_code, 200)
self.assertFalse(CashRegister.objects.filter(fiscal_number='FN-PRIM-2').exists())
self.assertIn('is_primary', resp.context['rro_form_errors'])
def test_new_query_opens_create_panel(self):
self._login(self.staff)
resp = self.client.get(
f'/rro/?pharmacy={self.pharmacy.pk}&new=1',
HTTP_HX_REQUEST='true',
)
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.context['show_create_panel'])
self.assertEqual(str(resp.context['prefill_pharmacy']), str(self.pharmacy.pk))
+2
View File
@@ -4,4 +4,6 @@ from . import views
urlpatterns = [
path('rro/', views.rro_list_view, name='rro_list'),
path('rro/create/', views.rro_create_view, name='rro_create'),
path('rro/<int:pk>/update/', views.rro_update_view, name='rro_update'),
]
+220 -5
View File
@@ -1,5 +1,14 @@
from datetime import datetime
import json
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.views.decorators.http import require_POST
from apps.locations.models.pharmacies import Pharmacy
from core.models.directories import Directory
@@ -8,10 +17,112 @@ from core.utils.htmx import render_page
from .models import CashRegister
@login_required
def rro_list_view(request):
"""Реестр РРО/ПРРО по аптекам. Staff видит все; сотрудник аптеки — свою."""
def _can_edit_rro(user) -> bool:
return bool(user and user.is_authenticated and (user.is_staff or user.is_superuser))
def _parse_date(raw):
raw = (raw or '').strip()
if not raw:
return None
try:
return datetime.strptime(raw, '%Y-%m-%d').date()
except ValueError:
raise ValidationError({'__all__': ['Некорректная дата.']})
def _parse_register_form(post, *, instance=None):
"""Збирає поля з POST у dict для CashRegister + ValidationError map."""
errors = {}
pharmacy_id = post.get('pharmacy') or post.get('pharmacy_id') or None
if not pharmacy_id:
errors['pharmacy'] = ['Выберите аптеку.']
else:
try:
pharmacy_id = int(pharmacy_id)
except (TypeError, ValueError):
errors['pharmacy'] = ['Некорректная аптека.']
kind = (post.get('kind') or '').strip()
if kind not in {CashRegister.Kind.RRO, CashRegister.Kind.PRRO}:
errors['kind'] = ['Выберите тип (РРО / ПРРО).']
fiscal_number = (post.get('fiscal_number') or '').strip()
if not fiscal_number:
errors['fiscal_number'] = ['Укажите фискальный номер.']
status_id = post.get('status') or post.get('status_id') or None
if not status_id:
errors['status'] = ['Выберите статус.']
else:
try:
status_id = int(status_id)
except (TypeError, ValueError):
errors['status'] = ['Некорректный статус.']
vendor_raw = post.get('vendor') or post.get('vendor_id') or ''
vendor_id = None
if str(vendor_raw).strip():
try:
vendor_id = int(vendor_raw)
except (TypeError, ValueError):
errors['vendor'] = ['Некорректный вендор.']
try:
registered_at = _parse_date(post.get('registered_at'))
cert_expires_at = _parse_date(post.get('cert_expires_at'))
except ValidationError as exc:
errors.update(exc.message_dict if hasattr(exc, 'message_dict') else {'__all__': list(exc.messages)})
registered_at = None
cert_expires_at = None
data = {
'pharmacy_id': pharmacy_id,
'kind': kind,
'fiscal_number': fiscal_number,
'serial_number': (post.get('serial_number') or '').strip(),
'model_name': (post.get('model_name') or '').strip(),
'vendor_id': vendor_id,
'status_id': status_id,
'registered_at': registered_at,
'cert_expires_at': cert_expires_at,
'is_primary': bool(post.get('is_primary')),
'provider': (post.get('provider') or '').strip() if kind == CashRegister.Kind.PRRO else '',
'notes': (post.get('notes') or '').strip(),
}
form_data = {
**{k: ('' if v is None else v) for k, v in data.items()},
'registered_at': post.get('registered_at') or '',
'cert_expires_at': post.get('cert_expires_at') or '',
'is_primary': data['is_primary'],
'pharmacy': data['pharmacy_id'] or '',
'status': data['status_id'] or '',
'vendor': data['vendor_id'] or '',
}
if errors:
raise ValidationError(errors)
if instance is None:
register = CashRegister(**data)
else:
register = instance
for key, value in data.items():
setattr(register, key, value)
try:
register.full_clean()
except ValidationError:
raise
return register, form_data
def _rro_list_context(request, **extra):
is_staff = request.user.is_staff
can_edit = _can_edit_rro(request.user)
own_pharmacy = request.user.user_pharmacy
registers = CashRegister.objects.select_related(
@@ -23,6 +134,7 @@ def rro_list_view(request):
filter_kind = request.GET.get('kind', '')
filter_status = request.GET.get('status', '')
q = (request.GET.get('q') or '').strip()
show_create = request.GET.get('new') == '1'
if is_staff:
if filter_pharmacy:
@@ -50,17 +162,45 @@ def rro_list_view(request):
statuses = Directory.objects.filter(
directory_type__code='rro_status', is_active=True,
).order_by('name')
vendors = Directory.objects.filter(
directory_type__code='rro_vendor', is_active=True,
).order_by('name')
register_list = list(registers[:500])
registers_js = [
{
'id': reg.pk,
'pharmacy': str(reg.pharmacy_id),
'kind': reg.kind,
'fiscal_number': reg.fiscal_number,
'serial_number': reg.serial_number,
'model_name': reg.model_name,
'vendor': str(reg.vendor_id) if reg.vendor_id else '',
'status': str(reg.status_id),
'registered_at': reg.registered_at.isoformat() if reg.registered_at else '',
'cert_expires_at': reg.cert_expires_at.isoformat() if reg.cert_expires_at else '',
'provider': reg.provider,
'is_primary': reg.is_primary,
'notes': reg.notes,
}
for reg in register_list
]
context = {
'is_staff': is_staff,
'registers': list(registers[:500]),
'can_edit_rro': can_edit,
'registers': register_list,
'registers_json': json.dumps(registers_js, cls=DjangoJSONEncoder, ensure_ascii=False),
'statuses': statuses,
'vendors': vendors,
'kind_choices': CashRegister.Kind.choices,
'filter_pharmacy': filter_pharmacy,
'filter_org': filter_org,
'filter_kind': filter_kind,
'filter_status': filter_status,
'filter_q': q,
'show_create_panel': show_create and can_edit,
'prefill_pharmacy': filter_pharmacy if can_edit else '',
'all_pharmacies': (
Pharmacy.objects.filter(is_active=True)
.select_related('organization')
@@ -72,4 +212,79 @@ def rro_list_view(request):
if is_staff else []
),
}
return render_page(request, 'rro.html', context)
context.update(extra)
# Explicit open create panel on validation errors
if context.get('rro_form_errors') and not context.get('rro_edit_id'):
context['show_create_panel'] = True
if context.get('rro_form_data'):
context['rro_form_data_json'] = json.dumps(
context['rro_form_data'], cls=DjangoJSONEncoder, ensure_ascii=False,
)
return context
def _render_rro(request, **extra):
return render_page(request, 'rro.html', _rro_list_context(request, **extra))
@login_required
def rro_list_view(request):
"""Реестр РРО/ПРРО по аптекам. Staff видит все; сотрудник аптеки — свою."""
return _render_rro(request)
@login_required
@require_POST
def rro_create_view(request):
if not _can_edit_rro(request.user):
return HttpResponseForbidden()
form_data = dict(request.POST.items())
try:
register, form_data = _parse_register_form(request.POST)
with transaction.atomic():
register.save()
return _render_rro(request, rro_form_message='Кассовый аппарат добавлен')
except ValidationError as exc:
return _render_rro(
request,
show_create_panel=True,
rro_form_errors=exc.message_dict if hasattr(exc, 'message_dict') else {'__all__': list(exc.messages)},
rro_form_data=form_data,
)
except IntegrityError:
return _render_rro(
request,
show_create_panel=True,
rro_form_errors={'fiscal_number': ['Фискальный номер уже используется.']},
rro_form_data=form_data,
)
@login_required
@require_POST
def rro_update_view(request, pk):
if not _can_edit_rro(request.user):
return HttpResponseForbidden()
register = get_object_or_404(CashRegister, pk=pk)
form_data = dict(request.POST.items())
try:
register, form_data = _parse_register_form(request.POST, instance=register)
with transaction.atomic():
register.save()
return _render_rro(request, rro_form_message='Изменения сохранены')
except ValidationError as exc:
return _render_rro(
request,
rro_edit_id=pk,
rro_form_errors=exc.message_dict if hasattr(exc, 'message_dict') else {'__all__': list(exc.messages)},
rro_form_data=form_data,
)
except IntegrityError:
return _render_rro(
request,
rro_edit_id=pk,
rro_form_errors={'fiscal_number': ['Фискальный номер уже используется.']},
rro_form_data=form_data,
)