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:
@@ -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))
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user