faa06b9f82
- core/views.py: fix open redirect in login — validate next_url with url_has_allowed_host_and_scheme before redirecting - visit_manager/api/views.py: add explicit permission_classes/ authentication_classes on all 3 API views; add MIME magic-byte validation + Pillow verify for photo uploads; validate employee_id/ location_id/date query params; restrict photo access to is_staff, own record, or direct manager - employees/serializers.py: split into EmployeeSerializer (public fields only) and EmployeeDetailSerializer (PII fields, for HR/admin) - mattermost_service.py: sanitize sensitive fields (password, token) before logging; log only error message not full response body - settings/base.py: add SESSION/CSRF cookie HttpOnly + SameSite flags; set file upload size limits (15MB); remove BasicAuthentication from DRF (sends credentials in plaintext)
157 lines
5.7 KiB
Python
157 lines
5.7 KiB
Python
"""
|
|
REST API для регистрации посещений с PPOS/терминалов.
|
|
"""
|
|
import base64
|
|
import io
|
|
from datetime import datetime
|
|
|
|
from django.http import HttpResponseRedirect, Http404
|
|
from PIL import Image
|
|
from rest_framework import serializers, status
|
|
from rest_framework.authentication import SessionAuthentication
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from ..services import register_attendance
|
|
from ..models import AttendanceLog
|
|
|
|
_ALLOWED_MIME_SIGNATURES = {
|
|
b'\xff\xd8\xff': 'image/jpeg',
|
|
b'\x89PNG': 'image/png',
|
|
}
|
|
_MAX_PHOTO_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
|
|
|
|
class AttendanceCreateSerializer(serializers.Serializer):
|
|
employee_id = serializers.IntegerField(min_value=1)
|
|
location_id = serializers.IntegerField(min_value=1)
|
|
direction = serializers.ChoiceField(choices=['in', 'out'])
|
|
photo_b64 = serializers.CharField(write_only=True)
|
|
device_id = serializers.CharField(required=False, default='', max_length=100)
|
|
|
|
def validate_photo_b64(self, value: str) -> bytes:
|
|
try:
|
|
if ',' in value:
|
|
value = value.split(',', 1)[1]
|
|
photo_bytes = base64.b64decode(value)
|
|
except Exception:
|
|
raise serializers.ValidationError('Невалидный base64')
|
|
|
|
if len(photo_bytes) > _MAX_PHOTO_BYTES:
|
|
raise serializers.ValidationError('Фото превышает допустимый размер (10 МБ)')
|
|
|
|
# Проверяем сигнатуру файла (magic bytes)
|
|
mime = None
|
|
for sig, mime_type in _ALLOWED_MIME_SIGNATURES.items():
|
|
if photo_bytes[:len(sig)] == sig:
|
|
mime = mime_type
|
|
break
|
|
if mime is None:
|
|
raise serializers.ValidationError('Допускаются только JPEG и PNG изображения')
|
|
|
|
# Проверяем что файл действительно является изображением
|
|
try:
|
|
img = Image.open(io.BytesIO(photo_bytes))
|
|
img.verify()
|
|
except Exception:
|
|
raise serializers.ValidationError('Файл повреждён или не является изображением')
|
|
|
|
return photo_bytes
|
|
|
|
|
|
class AttendanceLogSerializer(serializers.ModelSerializer):
|
|
photo_thumb = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = AttendanceLog
|
|
fields = [
|
|
'id', 'employee_id', 'location_id', 'direction',
|
|
'timestamp', 'photo', 'photo_thumb', 'device_id',
|
|
]
|
|
|
|
def get_photo_thumb(self, obj):
|
|
return obj.photo_thumb_b64
|
|
|
|
|
|
class AttendanceRegisterView(APIView):
|
|
"""
|
|
POST /api/attendance/register/
|
|
Принимает фото в base64, создаёт запись посещения.
|
|
"""
|
|
authentication_classes = [SessionAuthentication]
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request):
|
|
serializer = AttendanceCreateSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
data = serializer.validated_data
|
|
|
|
log = register_attendance(
|
|
employee_id=data['employee_id'],
|
|
location_id=data['location_id'],
|
|
direction=data['direction'],
|
|
photo_bytes=data['photo_b64'],
|
|
device_id=data.get('device_id', ''),
|
|
ip_address=request.META.get('REMOTE_ADDR'),
|
|
)
|
|
return Response(AttendanceLogSerializer(log).data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class AttendanceListView(APIView):
|
|
"""
|
|
GET /api/attendance/?employee_id=&date=YYYY-MM-DD&location_id=
|
|
Список записей с превью (без оригиналов фото).
|
|
"""
|
|
authentication_classes = [SessionAuthentication]
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request):
|
|
qs = AttendanceLog.objects.select_related('employee', 'location')
|
|
|
|
if emp := request.query_params.get('employee_id'):
|
|
try:
|
|
qs = qs.filter(employee_id=int(emp))
|
|
except (ValueError, TypeError):
|
|
return Response({'error': 'employee_id должен быть числом'}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if loc := request.query_params.get('location_id'):
|
|
try:
|
|
qs = qs.filter(location_id=int(loc))
|
|
except (ValueError, TypeError):
|
|
return Response({'error': 'location_id должен быть числом'}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if date_str := request.query_params.get('date'):
|
|
try:
|
|
datetime.strptime(date_str, '%Y-%m-%d')
|
|
qs = qs.filter(timestamp__date=date_str)
|
|
except ValueError:
|
|
return Response({'error': 'Формат даты: YYYY-MM-DD'}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
qs = qs.order_by('-timestamp')[:200]
|
|
return Response(AttendanceLogSerializer(qs, many=True).data)
|
|
|
|
|
|
class AttendancePhotoView(APIView):
|
|
"""
|
|
GET /api/attendance/<uuid>/photo/
|
|
Redirect к оригиналу фото — только для is_staff или руководителя сотрудника.
|
|
"""
|
|
authentication_classes = [SessionAuthentication]
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request, pk):
|
|
try:
|
|
log = AttendanceLog.objects.select_related('employee').get(pk=pk)
|
|
except AttendanceLog.DoesNotExist:
|
|
raise Http404
|
|
|
|
if not log.photo:
|
|
raise Http404
|
|
|
|
user = request.user
|
|
if not (user.is_staff or user.pk == log.employee_id or user.pk == log.employee.manager_id):
|
|
raise Http404
|
|
|
|
return HttpResponseRedirect(log.photo.url)
|