Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/docker/kesaseteli.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ COPY --chown=default:root kesaseteli/requirements-prod.txt /app/requirements-pro
COPY --chown=default:root kesaseteli/.prod/escape_json.c /app/.prod/escape_json.c
COPY --chown=default:root shared /shared/

# WORKAROUND: pip 25.3 used until https://github.com/jazzband/pip-tools/issues/2319 really works.
# Otherwise "AttributeError: 'PackageFinder' object has no attribute 'allow_all_prereleases'"
# can happen when using pip-tools / pip-compile in development.
RUN dnf update -y \
&& dnf install -y \
git \
Expand All @@ -23,7 +26,7 @@ RUN dnf update -y \
xmlsec1-openssl \
cyrus-sasl-devel \
openssl-devel \
&& pip install -U pip setuptools wheel \
&& pip install -U pip==25.3 setuptools wheel \
&& pip install --no-cache-dir -r /app/requirements.txt \
&& pip install --no-cache-dir -r /app/requirements-prod.txt \
&& uwsgi --build-plugin /app/.prod/escape_json.c \
Expand Down
25 changes: 9 additions & 16 deletions backend/kesaseteli/applications/api/v1/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,16 @@ def has_employer_application_permission(
request: HttpRequest, employer_application: EmployerApplication
) -> bool:
"""
Allow access only for DRAFT status employer applications of the user &
company.
Allow access only to DRAFT status employer applications of the user's company.
"""
user = request.user

if user.is_staff or user.is_superuser:
return True

user_company = get_user_company(request)

if (
employer_application.company == user_company
and employer_application.user == user
and employer_application.status in ALLOWED_APPLICATION_VIEW_STATUSES
):
return True
return False
return (
request.user.is_staff
or request.user.is_superuser
or (
employer_application.company == get_user_company(request)
and employer_application.status in ALLOWED_APPLICATION_VIEW_STATUSES
)
)


class EmployerApplicationPermission(BasePermission):
Expand Down
4 changes: 3 additions & 1 deletion backend/kesaseteli/applications/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,8 @@ class Meta:
model = EmployerApplication
fields = [
"id",
"created_at",
"modified_at",
"status",
"street_address",
"bank_account_number",
Expand All @@ -367,7 +369,7 @@ class Meta:
"language",
"submitted_at",
]
read_only_fields = ["user"]
read_only_fields = ["created_at", "modified_at", "user"]

@transaction.atomic
def update(self, instance, validated_data):
Expand Down
119 changes: 104 additions & 15 deletions backend/kesaseteli/applications/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from django.conf import settings
from django.core import exceptions
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import F, Func
from django.db.utils import ProgrammingError
Expand All @@ -14,6 +15,7 @@
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_protect
from django_filters import rest_framework as filters
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
Expand Down Expand Up @@ -654,23 +656,98 @@ def create_without_ssn(self, request, *args, **kwargs) -> HttpResponse:
raise


class EmployerApplicationFilter(filters.FilterSet):
"""
Filtering & sorting support for EmployerApplicationViewSet
"""

only_mine = filters.BooleanFilter(method="filter_only_mine")
status = filters.MultipleChoiceFilter(choices=EmployerApplicationStatus.choices)
created_at = filters.DateTimeFromToRangeFilter()
modified_at = filters.DateTimeFromToRangeFilter()
ordering = filters.OrderingFilter(
# (Model field name, parameter name) tuples:
fields=[
# Present model field names directly as the query parameters:
(field_name, field_name)
for field_name in [
"company__business_id",
"company__name",
"company_id",
"created_at",
"id",
"modified_at",
"status",
"user__first_name",
"user__last_name",
"user_id",
]
]
)

def _filter_by_user(self, queryset):
"""
Filter queryset by the current user, if they're authenticated,
otherwise return empty queryset.
"""
user = self.request.user
if user and user.is_authenticated:
return queryset.filter(user=user)
return queryset.none()

def filter_only_mine(self, queryset, name, value):
"""
Filter to show only current user's employer applications
"""
if value:
return self._filter_by_user(queryset)
return queryset

@property
def qs(self):
"""
Queryset property overridden to apply only_mine filter by default
when not provided or empty.
"""
parent_qs = super().qs

# Apply only_mine filter by default when not provided or empty
if self.data.get("only_mine") in (None, ""):
return self._filter_by_user(parent_qs)

return parent_qs

class Meta:
model = EmployerApplication
_timestamp_field_lookups = [
f"{prefix}{filter}"
for prefix in ["", "year__", "date__"]
for filter in ["gte", "lte", "gt", "lt", "exact"]
]
fields = {
"company__business_id": ["exact", "in"],
"company_id": ["exact", "in"],
"created_at": _timestamp_field_lookups,
"modified_at": _timestamp_field_lookups,
"status": ["exact", "in"],
"user_id": ["exact", "in"],
}


class EmployerApplicationViewSet(AuditLoggingModelViewSet):
queryset = EmployerApplication.objects.all()
serializer_class = EmployerApplicationSerializer
permission_classes = [IsAuthenticated, EmployerApplicationPermission]
pagination_class = LimitOffsetPagination
filter_backends = [filters.DjangoFilterBackend]
filterset_class = EmployerApplicationFilter

def get_queryset(self):
"""
Fetch all DRAFT status applications of the user & company.

Should inlcude only 1 application since we don't allow creation of
multiple DRAFT applications per user & company.
"""
queryset = (
super()
.get_queryset()
.select_related("company")
.select_related("user")
.prefetch_related("summer_vouchers")
)

Expand All @@ -682,16 +759,20 @@ def get_queryset(self):

return queryset.filter(
company=user_company,
user=user,
status__in=ALLOWED_APPLICATION_VIEW_STATUSES,
)

def create(self, request, *args, **kwargs):
"""
Allow only 1 (DRAFT) application per user & company.
"""
if self.get_queryset().filter(status=EmployerApplicationStatus.DRAFT).exists():
if (
self.get_queryset()
.filter(status=EmployerApplicationStatus.DRAFT, user=request.user)
.exists()
):
raise ValidationError("Company & user can have only one draft application")

return super().create(request, *args, **kwargs)

def update(self, request, *args, **kwargs):
Expand All @@ -701,6 +782,9 @@ def update(self, request, *args, **kwargs):
instance = self.get_object()
if instance.status not in ALLOWED_APPLICATION_UPDATE_STATUSES:
raise ValidationError("Only DRAFT applications can be updated")
if request.user != instance.user:
raise PermissionDenied("Only application creator can update it")

return super().update(request, *args, **kwargs)

def destroy(self, request, *args, **kwargs):
Expand All @@ -717,8 +801,8 @@ class EmployerSummerVoucherViewSet(AuditLoggingModelViewSet):

def get_queryset(self):
"""
Fetch summer vouchers of DRAFT status applications of the user &
company.
Fetch summer vouchers of DRAFT/SUBMITTED status applications
of the user's company.
"""
queryset = (
super()
Expand All @@ -728,7 +812,7 @@ def get_queryset(self):
)

user = self.request.user
if user.is_staff:
if user.is_staff or user.is_superuser:
return queryset
elif user.is_anonymous:
return queryset.none()
Expand All @@ -737,7 +821,6 @@ def get_queryset(self):

return queryset.filter(
application__company=user_company,
application__user=user,
application__status__in=ALLOWED_APPLICATION_VIEW_STATUSES,
)

Expand Down Expand Up @@ -772,6 +855,8 @@ def post_attachment(self, request, *args, **kwargs):
raise ValidationError(
"Attachments can be uploaded only for DRAFT applications"
)
if obj.application.user != request.user:
raise PermissionDenied("Only application creator can post attachment to it")

# Validate request data
serializer = AttachmentSerializer(
Expand Down Expand Up @@ -821,15 +906,19 @@ def handle_attachment(self, request, attachment_pk, *args, **kwargs):
raise ValidationError(
"Attachments can be deleted only for DRAFT applications"
)
if obj.application.user != request.user:
raise PermissionDenied(
"Only application creator can delete attachment from it"
)

if (
obj.application.status
not in AttachmentSerializer.ATTACHMENT_MODIFICATION_ALLOWED_STATUSES
):
return Response(
{"detail": _("Operation not allowed for this application status.")},
status=status.HTTP_403_FORBIDDEN,
raise PermissionDenied(
"Operation not allowed for this application status."
)

try:
instance = obj.attachments.get(id=attachment_pk)
except exceptions.ObjectDoesNotExist:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 5.1.15 on 2026-02-09 14:14

from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
(
"applications",
"0047_alter_historicalyouthsummervoucher_target_group_and_more",
),
("companies", "0011_company_add_name_index"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AddIndex(
model_name="employerapplication",
index=models.Index(
fields=["created_at"], name="employer_app_created_at_idx"
),
),
migrations.AddIndex(
model_name="employerapplication",
index=models.Index(
fields=["modified_at"], name="employer_app_modified_at_idx"
),
),
migrations.AddIndex(
model_name="employerapplication",
index=models.Index(fields=["status"], name="employer_app_status_idx"),
),
]
5 changes: 5 additions & 0 deletions backend/kesaseteli/applications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,11 @@ class Meta:
verbose_name = _("employer application")
verbose_name_plural = _("employer applications")
ordering = ["-created_at", "id"]
indexes = [
models.Index(fields=["created_at"], name="employer_app_created_at_idx"),
models.Index(fields=["modified_at"], name="employer_app_modified_at_idx"),
models.Index(fields=["status"], name="employer_app_status_idx"),
]


class EmployerSummerVoucher(HistoricalModel, TimeStampedModel, UUIDModel):
Expand Down
Loading
Loading