diff --git a/.env.benefit-backend.example b/.env.benefit-backend.example index 402841981c..3e6181b5d8 100644 --- a/.env.benefit-backend.example +++ b/.env.benefit-backend.example @@ -61,14 +61,18 @@ MEDIA_ROOT=/app/var/media CSRF_COOKIE_NAME=yjdhcsrftoken CSRF_TRUSTED_ORIGINS="localhost:3000,localhost:3100" -YRTTI_TIMEOUT=30 -YRTTI_BASIC_INFO_PATH=https://yrtti-integration-test.agw.arodevtest.hel.fi/api/BasicInfo -SERVICE_BUS_INFO_PATH=https://ytj-integration-test.agw.arodevtest.hel.fi/api/GetCompany +YRTTI_BASE_URL=https://yrtti-integration-test.agw.arodevtest.hel.fi/api YRTTI_AUTH_PASSWORD= YRTTI_AUTH_USERNAME=helsinkilisatest +YRTTI_TIMEOUT=30 +YRTTI_SEARCH_LIMIT=10 +YRTTI_DISABLE=0 +SERVICE_BUS_BASE_URL=https://ytj-integration-test.agw.arodevtest.hel.fi/api SERVICE_BUS_AUTH_PASSWORD= SERVICE_BUS_AUTH_USERNAME=helsinkilisatest +SERVICE_BUS_TIMEOUT=30 +SERVICE_BUS_SEARCH_LIMIT=10 SEND_AUDIT_LOG=0 @@ -98,10 +102,3 @@ SENTRY_ENVIRONMENT=local # for Mailhog inbox EMAIL_HOST=mailhog EMAIL_PORT=1025 - -# Variables for using a S3 compatible disk in local development environment in upcoming staging / production environments -USE_S3=1 -S3_ENDPOINT_URL="http://minio:9000" -S3_ACCESS_KEY_ID=minio-root -S3_SECRET_ACCESS_KEY=minio-pass -S3_STORAGE_BUCKET_NAME=local-s3-bucket \ No newline at end of file diff --git a/azure-pipelines/helsinkilisa-review.yml b/azure-pipelines/helsinkilisa-review.yml new file mode 100644 index 0000000000..f17dcf73f8 --- /dev/null +++ b/azure-pipelines/helsinkilisa-review.yml @@ -0,0 +1,65 @@ +# +# Review pipeline. Run build and deploy for Platta test environments. +# Pipeline runs different tests e.g. unittest and browser tests. +# +# Continuous integration (CI) triggers cause a pipeline to run whenever you push +# an update to the specified branches or you push specified tags. +# only PR trigger pipeline +trigger: none + +# Pull request (PR) triggers cause a pipeline to run whenever a pull request is +# opened with one of the specified target branches, or when updates are made to +# such a pull request. +# +# GitHub creates a new ref when a pull request is created. The ref points to a +# merge commit, which is the merged code between the source and target branches +# of the pull request. +# +# Opt out of pull request validation +pr: + # PR target branch + branches: + include: + - develop + paths: + include: + - azure-pipelines/helsinkilisa-review.yml + - backend/docker/benefit.Dockerfile + - backend/benefit/** + - backend/shared/** + - frontend/* + - frontend/benefit/** + - frontend/shared/** + exclude: + - README.md + - backend/kesaseteli/** + - backend/tet/** + - frontend/kesaseteli/** + - frontend/tet/** + - frontend/**/browser-tests + - frontend/**/__tests__ + +# By default, use self-hosted agents +pool: Default + +resources: + repositories: + # Azure DevOps repository + - repository: yjdh-helsinkilisa-pipelines + type: git + # Azure DevOps project/repository + name: yjdh-helsinkilisa/yjdh-helsinkilisa-pipelines + +extends: + # Filename in Azure DevOps Repository (note possible -ui or -api) + # Django example: azure-pipelines-PROJECTNAME-api-release.yml + # Drupal example: azure-pipelines-drupal-release.yml + template: azure-pipelines-helsinkilisa-review.yml@yjdh-helsinkilisa-pipelines + # Application build arguments and config map values as key value pairs. + # The values here will override the values defined in the yjdh-benefit-pipelines repository. + # for example +# parameters: +# buildArgs: +# NEXT_PUBLIC_DEBUG: 0 +# configMap: # pod environment variables +# DEBUG: 0 diff --git a/backend/benefit/README.md b/backend/benefit/README.md index 7e2f27accb..7d2a5b6c7a 100644 --- a/backend/benefit/README.md +++ b/backend/benefit/README.md @@ -56,7 +56,6 @@ Set default permissions This creates permissions for the handler's group so they have access to the Terms in the django admin. - ### Configure docker environment In the yjdh project root, set up the .env.benefit-backend file: `cp .env.benefit-backend.example .env.benefit-backend` @@ -86,6 +85,7 @@ The project is now running at [localhost:8000](https://localhost:8000) ### Updating translations In `backend/benefit/`: + - Run `python manage.py makemessages --no-location -l fi -l sv -l en` - Run `python manage.py compilemessages` @@ -98,7 +98,7 @@ DUMMY_COMPANY_FORM_CODE can be set to test with different company_form parameter To seed the database with some mock application data, run `python manage.py seed` , which by default generates 10 applications for each of the seven possible application statuses and one attachment with a .pdf-file for each of them. To generate more applications, use the optional `--number` flag, for example, running `python manage.py seed --number=30` creates 30 applications of each status. **Note that running the command deletes all previous application data from the database and clears the media folder.** -[Mailhog](https://github.com/mailhog) is available for the local development environment (localhost:8025)[http://localhost:8025/] for previewing +[Mailhog](https://github.com/mailhog) is available for the local development environment (localhost:8025)[http://localhost:8025/] for previewing and testing the emails sent by the application after setting the `EMAIL_HOST` and `EMAIL_PORT` as in the `.env.benefit-backend.example`. **Using LOAD_FIXTURES=1 is recommended for local testing** as it loads e.g. default @@ -176,7 +176,7 @@ and redoc documentation at [https://localhost:8000/api_docs/redoc/](https://loca ## Scheduled jobs -Jobs can be scheduled using the Django extensions-package and setting the jobs to run as a cronjob. +Jobs can be scheduled using the Django extensions-package and setting the jobs to run as a cronjob. Currently configured jobs (registered in the `applications/jobs`-directory): - Daily: check applications that have been in the cancelled state for 30 or more days and delete them. @@ -208,24 +208,8 @@ env variables / settings are provided by Azure blob storage: - `AZURE_ACCOUNT_KEY` - `AZURE_CONTAINER` -An AWS S3 compatible disk storage can be configured with the following environment variables. - -- `USE_S3` -- `S3_ENDPOINT_URL` -- `S3_ACCESS_KEY_ID` -- `S3_SECRET_ACCESS_KEY` -- `S3_STORAGE_BUCKET_NAME` - -[MinIO](https://min.io/) can been configured to work as the AWS S3 compatible file storage on the local development environment. The MinIO admin panel can be accessed at (localhost:9090)[http://localhost:9090/]. -See `.env.benefit-backend.example` for the Minio variables and credentials. - -**Note** -As tests freeze time with [freezegun](https://github.com/spulec/freezegun), the MinIO requests fail when running tests with exception `botocore.exceptions.ClientError: An error occurred (RequestTimeTooSkewed) when calling the PutObject operation: The difference between the request time and the server's time is too large.` - -Switch from MinIO to the local disk by setting `USE_S3=0` before running the pytest tests. For now, the only workaround to run tests with S3 enabled is to set host machine date to the date that is used in tests: `2021-06-04 00:00:00 (UTC)`. - - ## Sentry error monitoring -The `local`, `development` and `testing` environments are connected to the Sentry instance at [`https://sentry.test.hel.ninja/`](https://sentry.test.hel.ninja/) under the `yjdh-benefit`-team. -There are separate Sentry projects for the Django api (`yjdh-benefit-api`), handler UI (`yjdh-benefit-handler`) and applicant UI (`yjdh-benefit-applicant`). + +The `local`, `development` and `testing` environments are connected to the Sentry instance at [`https://sentry.test.hel.ninja/`](https://sentry.test.hel.ninja/) under the `yjdh-benefit`-team. +There are separate Sentry projects for the Django api (`yjdh-benefit-api`), handler UI (`yjdh-benefit-handler`) and applicant UI (`yjdh-benefit-applicant`). To limit the amount of possibly sensitive data sent to Sentry, the same configuration as in kesaseteli is used by default, see [`https://github.com/City-of-Helsinki/yjdh/pull/779`](https://github.com/City-of-Helsinki/yjdh/pull/779). diff --git a/backend/benefit/applications/admin.py b/backend/benefit/applications/admin.py index 7d74362a1b..9f0274cbb2 100644 --- a/backend/benefit/applications/admin.py +++ b/backend/benefit/applications/admin.py @@ -45,10 +45,11 @@ class ApplicationAdmin(admin.ModelAdmin): AttachmentInline, CalculationInline, ) - list_filter = ("status", "company") + list_filter = ("status", "application_origin", "company") list_display = ( "id", "status", + "application_origin", "application_number", "company_name", "company_contact_person_email", diff --git a/backend/benefit/applications/api/v1/application_batch_views.py b/backend/benefit/applications/api/v1/application_batch_views.py index fc0fbeeadd..2443eae943 100755 --- a/backend/benefit/applications/api/v1/application_batch_views.py +++ b/backend/benefit/applications/api/v1/application_batch_views.py @@ -191,7 +191,7 @@ def assign_applications(self, request): def create_application_batch_by_ids(app_status, apps): if apps: batch = ApplicationBatch.objects.create( - proposal_for_decision=app_status + proposal_for_decision=app_status, handler=request.user ) return batch @@ -209,16 +209,23 @@ def create_application_batch_by_ids(app_status, apps): ) # Try finding an existing batch - batch = ( - ApplicationBatch.objects.filter( - status=ApplicationBatchStatus.DRAFT, proposal_for_decision=app_status - ).first() - ) or create_application_batch_by_ids( - app_status, - apps, - ) + try: + batch = ( + ApplicationBatch.objects.filter( + status=ApplicationBatchStatus.DRAFT, + proposal_for_decision=app_status, + ).first() + ) or create_application_batch_by_ids( + app_status, + apps, + ) + except BatchTooManyDraftsError: + return Response( + {"errorKey": "batchInvalidDraftAlreadyExists"}, + status=status.HTTP_400_BAD_REQUEST, + ) - if batch: + if batch and batch.status == ApplicationBatchStatus.DRAFT: apps.update(batch=batch) batch = ApplicationBatchSerializer(batch) return Response(batch.data, status=status.HTTP_200_OK) @@ -239,16 +246,22 @@ def deassign_applications(self, request, pk=None): application_ids = request.data.get("application_ids") batch = self.get_batch(pk) - apps = Application.objects.filter( + deassign_apps = Application.objects.filter( + batch=batch, pk__in=application_ids, status__in=[ApplicationStatus.ACCEPTED, ApplicationStatus.REJECTED], - batch=batch, ) - if apps: - for app in apps: + if deassign_apps: + for app in deassign_apps: app.batch = None app.save() - return Response(status=status.HTTP_200_OK) + remaining_apps = Application.objects.filter(batch=batch) + if len(remaining_apps) == 0: + batch.delete() + return Response( + {"remainingApps": len(remaining_apps)}, status=status.HTTP_200_OK + ) + return Response( {"detail": "Applications were not applicable to be detached."}, status=status.HTTP_404_NOT_FOUND, @@ -264,9 +277,11 @@ def status(self, request, pk=None): batch = self.get_batch(pk) if new_status not in [ ApplicationBatchStatus.DRAFT, + ApplicationBatchStatus.AHJO_REPORT_CREATED, ApplicationBatchStatus.AWAITING_AHJO_DECISION, ApplicationBatchStatus.DECIDED_ACCEPTED, ApplicationBatchStatus.DECIDED_REJECTED, + ApplicationBatchStatus.SENT_TO_TALPA, ]: return Response(status=status.HTTP_400_BAD_REQUEST) @@ -274,13 +289,17 @@ def status(self, request, pk=None): ApplicationBatchStatus.DECIDED_ACCEPTED, ApplicationBatchStatus.DECIDED_REJECTED, ]: - # Archive all applications if this batch will be completed - Application.objects.filter(batch=batch).update(archived=True) - - # Patch all required fields for batch completion + # Patch all required fields after batch inspection for key in request.data: setattr(batch, key, request.data.get(key)) + if new_status in [ + ApplicationBatchStatus.SENT_TO_TALPA, + ]: + # Archive all applications if this batch will be completed + Application.objects.filter(batch=batch).update(archived=True) + + previous_status = batch.status batch.status = new_status try: @@ -309,6 +328,7 @@ def status(self, request, pk=None): { "id": batch.id, "status": batch.status, + "previousStatus": previous_status, "decision": batch.proposal_for_decision, }, status=status.HTTP_200_OK, diff --git a/backend/benefit/applications/api/v1/serializers/application.py b/backend/benefit/applications/api/v1/serializers/application.py index 71c16fab0f..346dd89b98 100755 --- a/backend/benefit/applications/api/v1/serializers/application.py +++ b/backend/benefit/applications/api/v1/serializers/application.py @@ -23,6 +23,7 @@ ) from applications.benefit_aggregation import get_former_benefit_info from applications.enums import ( + ApplicationOrigin, ApplicationStatus, AttachmentRequirement, AttachmentType, @@ -424,7 +425,14 @@ def get_applicant_terms_approval_needed(self, obj): @extend_schema_field(TermsSerializer()) def get_applicant_terms_in_effect(self, obj): - terms = Terms.objects.get_terms_in_effect(TermsType.APPLICANT_TERMS) + terms_map = { + ApplicationOrigin.APPLICANT: TermsType.APPLICANT_TERMS, + ApplicationOrigin.HANDLER: TermsType.HANDLER_TERMS, + } + terms_type = terms_map.get( + ApplicationOrigin(obj.application_origin), ApplicationOrigin.APPLICANT + ) + terms = Terms.objects.get_terms_in_effect(terms_type) if terms: # If given the request in context, DRF will output the URL for FileFields context = {"request": self.context.get("request")} @@ -532,6 +540,9 @@ def get_modified_at(self, obj): return None return obj.modified_at + def get_company_for_new_application(self, _): + raise NotImplementedError() + def _get_pay_subsidy_attachment_requirements(self, application): req = [] if application.pay_subsidy_percent: @@ -540,27 +551,50 @@ def _get_pay_subsidy_attachment_requirements(self, application): ) return req + @staticmethod + def _get_handler_attachment_requirements(application): + if application.application_origin == ApplicationOrigin.HANDLER: + return [ + (AttachmentType.FULL_APPLICATION, AttachmentRequirement.REQUIRED), + (AttachmentType.OTHER_ATTACHMENT, AttachmentRequirement.OPTIONAL), + ] + return [] + def get_attachment_requirements(self, obj): if obj.apprenticeship_program: - return [ - (AttachmentType.EMPLOYMENT_CONTRACT, AttachmentRequirement.REQUIRED), - (AttachmentType.EDUCATION_CONTRACT, AttachmentRequirement.REQUIRED), - ( - AttachmentType.HELSINKI_BENEFIT_VOUCHER, - AttachmentRequirement.OPTIONAL, - ), - ] + self._get_pay_subsidy_attachment_requirements(obj) + return ( + [ + ( + AttachmentType.EMPLOYMENT_CONTRACT, + AttachmentRequirement.REQUIRED, + ), + (AttachmentType.EDUCATION_CONTRACT, AttachmentRequirement.REQUIRED), + ( + AttachmentType.HELSINKI_BENEFIT_VOUCHER, + AttachmentRequirement.OPTIONAL, + ), + ] + + self._get_pay_subsidy_attachment_requirements(obj) + + self._get_handler_attachment_requirements(obj) + ) elif obj.benefit_type in [ BenefitType.EMPLOYMENT_BENEFIT, BenefitType.SALARY_BENEFIT, ]: - return [ - (AttachmentType.EMPLOYMENT_CONTRACT, AttachmentRequirement.REQUIRED), - ( - AttachmentType.HELSINKI_BENEFIT_VOUCHER, - AttachmentRequirement.OPTIONAL, - ), - ] + self._get_pay_subsidy_attachment_requirements(obj) + return ( + [ + ( + AttachmentType.EMPLOYMENT_CONTRACT, + AttachmentRequirement.REQUIRED, + ), + ( + AttachmentType.HELSINKI_BENEFIT_VOUCHER, + AttachmentRequirement.OPTIONAL, + ), + ] + + self._get_pay_subsidy_attachment_requirements(obj) + + self._get_handler_attachment_requirements(obj) + ) elif obj.benefit_type == BenefitType.COMMISSION_BENEFIT: return [ (AttachmentType.COMMISSION_CONTRACT, AttachmentRequirement.REQUIRED), @@ -568,7 +602,7 @@ def get_attachment_requirements(self, obj): AttachmentType.HELSINKI_BENEFIT_VOUCHER, AttachmentRequirement.OPTIONAL, ), - ] + ] + self._get_handler_attachment_requirements(obj) elif not obj.benefit_type: # applicant has not selected the value yet return [] @@ -1026,14 +1060,17 @@ def _validate_employee_consent(self, instance): consent_count = instance.attachments.filter( attachment_type=AttachmentType.EMPLOYEE_CONSENT ).count() - if consent_count == 0: - raise serializers.ValidationError( - _("Application does not have the employee consent attachment") - ) - if consent_count > 1: - raise serializers.ValidationError( - _("Application cannot have more than one employee consent attachment") - ) + if instance.application_origin == ApplicationOrigin.APPLICANT: + if consent_count == 0: + raise serializers.ValidationError( + _("Application does not have the employee consent attachment") + ) + if consent_count > 1: + raise serializers.ValidationError( + _( + "Application cannot have more than one employee consent attachment" + ) + ) def _update_applicant_terms_approval(self, instance, approve_terms): if ApplicantTermsApproval.terms_approval_needed(instance): @@ -1140,7 +1177,7 @@ def create(self, validated_data): return application def _update_or_create_employee(self, application, employee_data): - employee, created = Employee.objects.update_or_create( + employee, _ = Employee.objects.update_or_create( application=application, defaults=employee_data ) return employee @@ -1225,7 +1262,7 @@ class ApplicantApplicationSerializer(BaseApplicationSerializer): ), ) - def get_company_for_new_application(self, validated_data): + def get_company_for_new_application(self, _): """ Company field is read_only. When creating a new application, assign company. """ @@ -1280,10 +1317,9 @@ class HandlerApplicationSerializer(BaseApplicationSerializer): read_only=True, help_text="Application batch of this application, if any" ) - create_application_for_company = serializers.PrimaryKeyRelatedField( - write_only=True, + create_application_for_company = serializers.UUIDField( + read_only=True, required=False, - queryset=Company.objects.all(), help_text=( "To be used when a logged-in application handler creates a new application" " based on a paper applicationreceived via mail. Ordinary applicants can" @@ -1291,15 +1327,19 @@ class HandlerApplicationSerializer(BaseApplicationSerializer): ), ) - def get_company_for_new_application(self, validated_data): + def get_company_for_new_application(self, _): """ Company field is read_only. When creating a new application, assign company. """ - if not validated_data["create_application_for_company"]: + company_id = self.initial_data.get("create_application_for_company") + if not company_id: raise BenefitAPIException( _("create_application_for_company missing from request") ) - return Company.objects.get(validated_data["create_application_for_company"]) + company = Company.objects.get(id=company_id) + if not company: + raise BenefitAPIException(_(f"company with id {company_id} not found")) + return company handled_at = serializers.SerializerMethodField( "get_handled_at", @@ -1320,6 +1360,7 @@ class Meta(BaseApplicationSerializer.Meta): "create_application_for_company", "latest_decision_comment", "handled_at", + "application_origin", ] read_only_fields = BaseApplicationSerializer.Meta.read_only_fields + [ "latest_decision_comment", diff --git a/backend/benefit/applications/api/v1/serializers/batch.py b/backend/benefit/applications/api/v1/serializers/batch.py index 1f887fbc79..b0cc4e9a60 100755 --- a/backend/benefit/applications/api/v1/serializers/batch.py +++ b/backend/benefit/applications/api/v1/serializers/batch.py @@ -8,6 +8,7 @@ ) from applications.enums import ApplicationBatchStatus, ApplicationStatus from applications.models import Application, ApplicationBatch, Company, Employee +from users.api.v1.serializers import UserSerializer class ApplicationBatchSerializer(serializers.ModelSerializer): @@ -34,6 +35,14 @@ class ApplicationBatchSerializer(serializers.ModelSerializer): help_text="Proposed decision for Ahjo", ) + handler = UserSerializer( + help_text=( + "The handler object, with fields, currently assigned to this calculation" + " and application (read-only)" + ), + read_only=True, + ) + class Meta: model = ApplicationBatch fields = [ @@ -47,7 +56,12 @@ class Meta: "decision_date", "expert_inspector_name", "expert_inspector_email", + "expert_inspector_title", + "p2p_inspector_name", + "p2p_inspector_email", + "p2p_checker_name", "created_at", + "handler", ] read_only_fields = [ "created_at", @@ -165,6 +179,7 @@ class Meta: class BatchApplicationSerializer(ReadOnlySerializer): company = BatchCompanySerializer(read_only=True) employee = BatchEmployeeSerializer(read_only=True) + handled_at = serializers.SerializerMethodField( "get_handled_at", help_text=( diff --git a/backend/benefit/applications/api/v1/views.py b/backend/benefit/applications/api/v1/views.py index a87eced71a..f24429d45d 100755 --- a/backend/benefit/applications/api/v1/views.py +++ b/backend/benefit/applications/api/v1/views.py @@ -23,11 +23,16 @@ HandlerApplicationSerializer, ) from applications.api.v1.serializers.attachment import AttachmentSerializer -from applications.enums import ApplicationBatchStatus, ApplicationStatus +from applications.enums import ( + ApplicationBatchStatus, + ApplicationOrigin, + ApplicationStatus, +) from applications.models import Application, ApplicationBatch from applications.services.ahjo_integration import ( ExportFileInfo, generate_zip, + prepare_csv_file, prepare_pdf_files, ) from applications.services.applications_csv_report import ApplicationsCsvService @@ -144,29 +149,8 @@ def simplified_application_list(self, request): Convenience action for the frontends that by default excludes the fields that are not normally needed in application listing pages. """ - qs = self.filter_queryset(self.get_queryset()) context = self.get_serializer_context() - fields = set(context.get("fields", [])) - exclude_fields = set(context.get("exclude_fields", [])) - extra_exclude_fields = set(self.EXCLUDE_FIELDS_FROM_SIMPLE_LIST) - context["exclude_fields"] = list( - exclude_fields | (extra_exclude_fields - fields) - ) - - order_by = request.query_params.get("order_by") - if ( - order_by - and re.sub(r"^-", "", order_by) - in ApplicantApplicationSerializer.Meta.fields - ): - qs = qs.order_by(order_by) - - exclude_batched = request.query_params.get("exclude_batched") == "1" - if exclude_batched: - qs = qs.filter(batch__isnull=True) - - qs = qs.filter(archived=request.query_params.get("filter_archived") == "1") - + qs = self._get_simplified_queryset(request, context) serializer = self.serializer_class(qs, many=True, context=context) return Response(serializer.data, status=status.HTTP_200_OK) @@ -200,6 +184,31 @@ def post_attachment(self, request, *args, **kwargs): serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) + def _get_simplified_queryset(self, request, context) -> QuerySet: + qs = self.filter_queryset(self.get_queryset()) + fields = set(context.get("fields", [])) + exclude_fields = set(context.get("exclude_fields", [])) + extra_exclude_fields = set(self.EXCLUDE_FIELDS_FROM_SIMPLE_LIST) + context["exclude_fields"] = list( + exclude_fields | (extra_exclude_fields - fields) + ) + + order_by = request.query_params.get("order_by") + if ( + order_by + and re.sub(r"^-", "", order_by) + in ApplicantApplicationSerializer.Meta.fields + ): + qs = qs.order_by(order_by) + + exclude_batched = request.query_params.get("exclude_batched") == "1" + if exclude_batched: + qs = qs.filter(batch__isnull=True) + + qs = qs.filter(archived=request.query_params.get("filter_archived") == "1") + + return qs + def _get_attachment(self, attachment_pk): try: return self.get_object().attachments.get(id=attachment_pk) @@ -345,7 +354,6 @@ def get_queryset(self): return self._annotate_unread_messages_count( super() .get_queryset() - .exclude(status=ApplicationStatus.DRAFT) .select_related("batch", "calculation") .prefetch_related( "pay_subsidies", "training_compensations", "calculation__rows" @@ -360,6 +368,17 @@ def get_serializer_context(self): context.update({"exclude_fields": exclude_fields.split(",")}) return context + @action(methods=["get"], detail=False, url_path="simplified_list") + def simplified_application_list(self, request): + context = self.get_serializer_context() + qs = self._get_simplified_queryset(request, context) + qs = qs.exclude( + status=ApplicationStatus.DRAFT, + application_origin=ApplicationOrigin.APPLICANT, + ) + serializer = self.serializer_class(qs, many=True, context=context) + return Response(serializer.data, status=status.HTTP_200_OK) + @action(methods=["GET"], detail=False) def export_csv(self, request) -> StreamingHttpResponse: queryset = self.get_queryset() @@ -370,13 +389,22 @@ def export_csv(self, request) -> StreamingHttpResponse: @action(methods=["GET"], detail=False) @transaction.atomic - def export_applications_in_batch(self, request) -> HttpResponse: + def batch_pdf_files(self, request) -> HttpResponse: batch_id = request.query_params.get("batch_id") if batch_id: apps = Application.objects.filter(batch_id=batch_id) return self._csv_pdf_response(apps) return Response(status=status.HTTP_400_BAD_REQUEST) + @action(methods=["GET"], detail=False) + @transaction.atomic + def batch_p2p_file(self, request) -> HttpResponse: + batch_id = request.query_params.get("batch_id") + if batch_id: + apps = Application.objects.filter(batch_id=batch_id) + return self._csv_response(apps, True, True) + return Response(status=status.HTTP_400_BAD_REQUEST) + @action(methods=["GET"], detail=False) @transaction.atomic def export_new_accepted_applications_csv_pdf(self, request) -> HttpResponse: @@ -418,12 +446,18 @@ def _export_filename_without_suffix(): date=timezone.now().strftime("%Y%m%d_%H%M%S"), ) - def _csv_response(self, queryset: QuerySet[Application]) -> StreamingHttpResponse: + def _csv_response( + self, + queryset: QuerySet[Application], + prune_data_for_talpa: bool = False, + remove_quotes: bool = False, + ) -> StreamingHttpResponse: csv_service = ApplicationsCsvService( - queryset.order_by(self.APPLICATION_ORDERING) + queryset.order_by(self.APPLICATION_ORDERING), prune_data_for_talpa ) response = StreamingHttpResponse( - csv_service.get_csv_string_lines_generator(), content_type="text/csv" + csv_service.get_csv_string_lines_generator(remove_quotes), + content_type="text/csv", ) response["Content-Disposition"] = "attachment; filename={filename}.csv".format( filename=self._export_filename_without_suffix() @@ -440,21 +474,18 @@ def _csv_pdf_response( prune_data_for_talpa: bool = False, remove_quotes: bool = False, ) -> HttpResponse: - export_filename_without_suffix = self._export_filename_without_suffix() - csv_filename = f"{export_filename_without_suffix}.csv" - zip_filename = f"{export_filename_without_suffix}.zip" ordered_queryset = queryset.order_by(self.APPLICATION_ORDERING) - csv_service = ApplicationsCsvService(ordered_queryset, prune_data_for_talpa) - csv_file_content: bytes = csv_service.get_csv_string( - prune_data_for_talpa - ).encode("utf-8") - csv_file_info: ExportFileInfo = ExportFileInfo( - filename=csv_filename, - file_content=csv_file_content, - html_content="", # No HTML content + export_filename_without_suffix = self._export_filename_without_suffix() + + csv_file = prepare_csv_file( + ordered_queryset, prune_data_for_talpa, export_filename_without_suffix ) + pdf_files: List[ExportFileInfo] = prepare_pdf_files(ordered_queryset) - zip_file: bytes = generate_zip([csv_file_info] + pdf_files) + + zip_file: bytes = generate_zip([csv_file] + pdf_files) + zip_filename = f"{export_filename_without_suffix}.zip" + response: HttpResponse = HttpResponse( zip_file, content_type="application/x-zip-compressed" ) diff --git a/backend/benefit/applications/enums.py b/backend/benefit/applications/enums.py index c60577ccf0..269b1732f3 100644 --- a/backend/benefit/applications/enums.py +++ b/backend/benefit/applications/enums.py @@ -97,7 +97,9 @@ class AttachmentType(models.TextChoices): "education contract of the apprenticeship office" ) HELSINKI_BENEFIT_VOUCHER = "helsinki_benefit_voucher", _("helsinki benefit voucher") - EMPLOYEE_CONSENT = "employee_consent", _("helsinki benefit voucher") + EMPLOYEE_CONSENT = "employee_consent", _("employee consent") + FULL_APPLICATION = "full_application", _("full application") + OTHER_ATTACHMENT = "other_attachment", _("other attachment") class AttachmentRequirement(models.TextChoices): @@ -126,3 +128,8 @@ class AhjoDecision(models.TextChoices): # The possible decisions for Ahjo processing DECIDED_ACCEPTED = ApplicationBatchStatus.DECIDED_ACCEPTED DECIDED_REJECTED = ApplicationBatchStatus.DECIDED_REJECTED + + +class ApplicationOrigin(models.TextChoices): + HANDLER = "handler", _("Handler") + APPLICANT = "applicant", _("Applicant") diff --git a/backend/benefit/applications/management/commands/seed.py b/backend/benefit/applications/management/commands/seed.py index 302c2b2c59..6542e0f2a0 100755 --- a/backend/benefit/applications/management/commands/seed.py +++ b/backend/benefit/applications/management/commands/seed.py @@ -6,7 +6,11 @@ from django.core.management.base import BaseCommand from django.utils import timezone -from applications.enums import ApplicationBatchStatus, ApplicationStatus +from applications.enums import ( + ApplicationBatchStatus, + ApplicationOrigin, + ApplicationStatus, +) from applications.models import Application, ApplicationBasis, ApplicationBatch from applications.tests.factories import ( AdditionalInformationNeededApplicationFactory, @@ -29,13 +33,13 @@ def add_arguments(self, parser): parser.add_argument( "--number", type=int, - default=10, + default=5, help="Number of applications to create", ) def handle(self, *args, **options): - batch_count = 4 - total_created = (len(ApplicationStatus.values) + batch_count) * options[ + batch_count = 6 + total_created = ((len(ApplicationStatus.values) * 2) + batch_count) * options[ "number" ] if not settings.DEBUG: @@ -85,6 +89,7 @@ def _create_batch( elif proposal_for_decision == ApplicationStatus.REJECTED: apps.append(RejectedApplicationFactory()) batch.applications.set(apps) + batch.handler = User.objects.filter(is_staff=True).last() batch.save() f = faker.Faker() @@ -103,12 +108,13 @@ def _create_batch( for factory in factories: for _ in range(number): - random_datetime = f.past_datetime(tzinfo=pytz.UTC) - application = factory() - application.created_at = random_datetime - application.save() + for application_origin in ApplicationOrigin.values: + random_datetime = f.past_datetime(tzinfo=pytz.UTC) + application = factory(application_origin=application_origin) + application.created_at = random_datetime + application.save() - application.log_entries.all().update(created_at=random_datetime) + application.log_entries.all().update(created_at=random_datetime) _create_batch(ApplicationBatchStatus.DRAFT, ApplicationStatus.ACCEPTED) _create_batch(ApplicationBatchStatus.DRAFT, ApplicationStatus.REJECTED) @@ -120,6 +126,9 @@ def _create_batch( ApplicationBatchStatus.AWAITING_AHJO_DECISION, ApplicationStatus.REJECTED ) + _create_batch(ApplicationBatchStatus.DECIDED_ACCEPTED, ApplicationStatus.ACCEPTED) + _create_batch(ApplicationBatchStatus.DECIDED_REJECTED, ApplicationStatus.REJECTED) + cancelled_deletion_threshold = _past_datetime(30) draft_deletion_threshold = _past_datetime(180) draft_notify_threshold = _past_datetime(180 - 14) diff --git a/backend/benefit/applications/migrations/0032_auto_20230526_1010.py b/backend/benefit/applications/migrations/0032_auto_20230526_1010.py new file mode 100755 index 0000000000..1e843226c8 --- /dev/null +++ b/backend/benefit/applications/migrations/0032_auto_20230526_1010.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.18 on 2023-05-26 07:10 + +import applications.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('applications', '0031_application_batches_expert_inspector_title'), + ] + + operations = [ + migrations.AddField( + model_name='applicationbatch', + name='handler', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='applicationbatch', + name='decision_date', + field=models.DateField(blank=True, null=True, validators=[applications.models.validate_decision_date], verbose_name='date of the decision in Ahjo'), + ), + ] diff --git a/backend/benefit/applications/migrations/0033_add_origin_and_attachment_types.py b/backend/benefit/applications/migrations/0033_add_origin_and_attachment_types.py new file mode 100644 index 0000000000..9f2eb141c9 --- /dev/null +++ b/backend/benefit/applications/migrations/0033_add_origin_and_attachment_types.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.18 on 2023-06-15 09:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('applications', '0032_auto_20230526_1010'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='application_origin', + field=models.CharField(choices=[('handler', 'Handler'), ('applicant', 'Applicant')], default='applicant', max_length=64, verbose_name='application origin'), + ), + migrations.AddField( + model_name='historicalapplication', + name='application_origin', + field=models.CharField(choices=[('handler', 'Handler'), ('applicant', 'Applicant')], default='applicant', max_length=64, verbose_name='application origin'), + ), + migrations.AlterField( + model_name='applicationbatch', + name='handler', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='attachment', + name='attachment_type', + field=models.CharField(choices=[('employment_contract', 'employment contract'), ('pay_subsidy_decision', 'pay subsidy decision'), ('commission_contract', 'commission contract'), ('education_contract', 'education contract of the apprenticeship office'), ('helsinki_benefit_voucher', 'helsinki benefit voucher'), ('employee_consent', 'employee consent'), ('full_application', 'full application'), ('other_attachment', 'other attachment')], max_length=64, verbose_name='attachment type in business rules'), + ), + ] diff --git a/backend/benefit/applications/migrations/0034_batch_talpa_inspectors.py b/backend/benefit/applications/migrations/0034_batch_talpa_inspectors.py new file mode 100644 index 0000000000..8d081b6ac2 --- /dev/null +++ b/backend/benefit/applications/migrations/0034_batch_talpa_inspectors.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.18 on 2023-06-15 11:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("applications", "0033_add_origin_and_attachment_types"), + ] + + operations = [ + migrations.AddField( + model_name="applicationbatch", + name="p2p_checker_name", + field=models.CharField( + blank=True, max_length=64, verbose_name="P2P acceptor's title" + ), + ), + migrations.AddField( + model_name="applicationbatch", + name="p2p_inspector_email", + field=models.EmailField( + blank=True, max_length=254, verbose_name="P2P inspector's email address" + ), + ), + migrations.AddField( + model_name="applicationbatch", + name="p2p_inspector_name", + field=models.CharField( + blank=True, max_length=128, verbose_name="P2P inspector's name" + ), + ), + ] diff --git a/backend/benefit/applications/migrations/0035_alter_applicationbatch_handler.py b/backend/benefit/applications/migrations/0035_alter_applicationbatch_handler.py new file mode 100644 index 0000000000..cb708c7ea7 --- /dev/null +++ b/backend/benefit/applications/migrations/0035_alter_applicationbatch_handler.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.18 on 2023-06-15 11:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("applications", "0034_batch_talpa_inspectors"), + ] + + operations = [ + migrations.AlterField( + model_name="applicationbatch", + name="handler", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/backend/benefit/applications/models.py b/backend/benefit/applications/models.py index 98499be215..6aa2f676c2 100755 --- a/backend/benefit/applications/models.py +++ b/backend/benefit/applications/models.py @@ -13,6 +13,7 @@ from applications.enums import ( AhjoDecision, ApplicationBatchStatus, + ApplicationOrigin, ApplicationStatus, ApplicationStep, AttachmentType, @@ -27,6 +28,7 @@ from common.utils import DurationMixin from companies.models import Company from shared.models.abstract_models import TimeStampedModel, UUIDModel +from users.models import User # todo: move to some better location? APPLICATION_LANGUAGE_CHOICES = ( @@ -137,6 +139,13 @@ class Application(UUIDModel, TimeStampedModel, DurationMixin): default=ApplicationStatus.DRAFT, ) + application_origin = models.CharField( + max_length=64, + verbose_name=_("application origin"), + choices=ApplicationOrigin.choices, + default=ApplicationOrigin.APPLICANT, + ) + application_number = models.IntegerField( verbose_name=_("application number"), default=_get_next_application_number ) @@ -476,6 +485,13 @@ class ApplicationBatch(UUIDModel, TimeStampedModel): * Transferring payment data to Talpa """ + handler = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + status = models.CharField( max_length=64, verbose_name=_("status of batch"), @@ -504,6 +520,16 @@ class ApplicationBatch(UUIDModel, TimeStampedModel): blank=True, validators=[validate_decision_date], ) + p2p_inspector_name = models.CharField( + max_length=128, blank=True, verbose_name=_("P2P inspector's name") + ) + p2p_inspector_email = models.EmailField( + blank=True, verbose_name=_("P2P inspector's email address") + ) + p2p_checker_name = models.CharField( + max_length=64, blank=True, verbose_name=_("P2P acceptor's title") + ) + expert_inspector_name = models.CharField( max_length=128, blank=True, verbose_name=_("Expert inspector's name") ) @@ -524,7 +550,11 @@ def _clean_one_draft_per_decision(self): and self.status == ApplicationBatchStatus.DRAFT ): drafts = ApplicationBatch.objects.filter( - status=self.status, proposal_for_decision=self.proposal_for_decision + status__in=[ + ApplicationBatchStatus.DRAFT, + ApplicationBatchStatus.AHJO_REPORT_CREATED, + ], + proposal_for_decision=self.proposal_for_decision, ).exclude(id=self.id) if len(drafts) > 0: raise BatchTooManyDraftsError( @@ -534,25 +564,40 @@ def _clean_one_draft_per_decision(self): ) def _clean_require_batch_data_on_completion(self): + def raise_error(): + raise BatchCompletionRequiredFieldsError( + "Required batch fields are missing!" + ) + if self.status not in [ ApplicationBatchStatus.DECIDED_ACCEPTED, ApplicationBatchStatus.DECIDED_REJECTED, ]: return - if not all( - [ - self.decision_maker_title, - self.decision_maker_name, - self.section_of_the_law, - validate_decision_date(self.decision_date), - self.expert_inspector_name, - self.expert_inspector_title, - ] + required_fields_rejected = [ + self.decision_maker_title, + self.decision_maker_name, + self.section_of_the_law, + validate_decision_date(self.decision_date), + self.expert_inspector_name, + self.expert_inspector_title, + ] + + required_fields_accepted = required_fields_rejected + [ + self.p2p_inspector_name, + self.p2p_inspector_email, + self.p2p_checker_name, + ] + + if self.status == ApplicationBatchStatus.DECIDED_ACCEPTED and not all( + required_fields_accepted ): - raise BatchCompletionRequiredFieldsError( - "Required batch fields are missing!" - ) + raise_error() + if self.status == ApplicationBatchStatus.DECIDED_REJECTED and not all( + required_fields_rejected + ): + raise_error() _clean_require_batch_data_on_completion(self) _clean_one_draft_per_decision(self) diff --git a/backend/benefit/applications/services/ahjo_integration.py b/backend/benefit/applications/services/ahjo_integration.py index 5689442f36..d5f9ed85e5 100644 --- a/backend/benefit/applications/services/ahjo_integration.py +++ b/backend/benefit/applications/services/ahjo_integration.py @@ -12,6 +12,7 @@ from applications.enums import ApplicationStatus from applications.models import Application +from applications.services.applications_csv_report import ApplicationsCsvService from companies.models import Company @@ -233,6 +234,25 @@ def prepare_pdf_files(apps: QuerySet[Application]) -> List[ExportFileInfo]: return pdf_files +def prepare_csv_file( + ordered_queryset: QuerySet[Application], + prune_data_for_talpa: bool = False, + export_filename: str = "", +) -> ExportFileInfo: + csv_service = ApplicationsCsvService(ordered_queryset, prune_data_for_talpa) + csv_file_content: bytes = csv_service.get_csv_string(prune_data_for_talpa).encode( + "utf-8" + ) + csv_filename = f"{export_filename}.csv" + csv_file_info: ExportFileInfo = ExportFileInfo( + filename=csv_filename, + file_content=csv_file_content, + html_content="", # No HTML content + ) + + return csv_file_info + + def generate_pdf( apps: List[Application], template_config: dict, diff --git a/backend/benefit/applications/services/applications_csv_report.py b/backend/benefit/applications/services/applications_csv_report.py index 1e5cc654a3..dbfba7f204 100644 --- a/backend/benefit/applications/services/applications_csv_report.py +++ b/backend/benefit/applications/services/applications_csv_report.py @@ -9,7 +9,7 @@ ) -def CsvDefaultColumn(*args, **kwargs): +def csv_default_column(*args, **kwargs): # define a default value, as the application csv export needs to be able to handle # also applications with missing data # Not defined as a subclass of CsvColumn due to the way Python dataclasses work @@ -86,6 +86,7 @@ def __init__(self, applications, prune_data_for_talpa=False): @property def CSV_COLUMNS(self): + calculated_benefit_amount = "calculation.calculated_benefit_amount" """Return only columns that are needed for Talpa""" if self.prune_data_for_talpa: talpa_columns = [ @@ -97,20 +98,26 @@ def CSV_COLUMNS(self): CsvColumn("Työnantajan katuosoite", "effective_company_street_address"), CsvColumn("Työnantajan postinumero", "effective_company_postcode"), CsvColumn("Työnantajan postitoimipaikka", "effective_company_city"), - CsvDefaultColumn( - "Helsinki-lisän määrä lopullinen", - "calculation.calculated_benefit_amount", + csv_default_column( + "Helsinki-lisän määrä lopullinen", calculated_benefit_amount ), - CsvDefaultColumn("Päättäjän nimike", "batch.decision_maker_title"), - CsvDefaultColumn("Päättäjän nimi", "batch.decision_maker_name"), - CsvDefaultColumn("Päätöspykälä", "batch.section_of_the_law"), - CsvDefaultColumn("Päätöspäivä", "batch.decision_date"), - CsvDefaultColumn( - "Asiantarkastajan nimi", "batch.expert_inspector_name" + csv_default_column("Päättäjän nimike", "batch.decision_maker_title"), + csv_default_column("Päättäjän nimi", "batch.decision_maker_name"), + csv_default_column("Päätöspykälä", "batch.section_of_the_law"), + csv_default_column("Päätöspäivä", "batch.decision_date"), + csv_default_column( + "Asiantarkastajan nimi Ahjo", "batch.expert_inspector_name" ), - CsvDefaultColumn( - "Asiantarkastajan email", "batch.expert_inspector_email" + csv_default_column( + "Asiantarkastajan titteli Ahjo", "batch.expert_inspector_title" ), + csv_default_column( + "Asiantarkastajan nimi P2P", "batch.p2p_inspector_name" + ), + csv_default_column( + "Asiantarkastajan email P2P", "batch.p2p_inspector_email" + ), + csv_default_column("Hyväksyjän nimi P2P", "batch.p2p_checker_name"), ] return talpa_columns @@ -118,9 +125,9 @@ def CSV_COLUMNS(self): CsvColumn("Hakemusnumero", "application_number"), CsvColumn("Hakemusrivi", "application_row_idx"), CsvColumn("Hakemuksen tila", "status"), - CsvDefaultColumn("Haettava lisä", "benefit_type", get_benefit_type_label), - CsvDefaultColumn("Haettu alkupäivä", "start_date"), - CsvDefaultColumn("Haettu päättymispäivä", "end_date"), + csv_default_column("Haettava lisä", "benefit_type", get_benefit_type_label), + csv_default_column("Haettu alkupäivä", "start_date"), + csv_default_column("Haettu päättymispäivä", "end_date"), CsvColumn("Työnantajan tyyppi", get_organization_type), CsvColumn("Työnantajan tilinumero", "company_bank_account_number"), CsvColumn("Työnantajan nimi", "company_name"), @@ -180,13 +187,13 @@ def CSV_COLUMNS(self): CsvColumn("YT-neuvottelut?", "co_operation_negotiations", format_bool), CsvColumn("YT-neuvottelut/tiedot", "co_operation_negotiations_description"), CsvColumn("Palkkatuki myönnetty?", "pay_subsidy_granted", format_bool), - CsvDefaultColumn("Palkkatukiprosentti", "pay_subsidy_percent"), - CsvDefaultColumn( + csv_default_column("Palkkatukiprosentti", "pay_subsidy_percent"), + csv_default_column( "Toinen palkkatukiprosentti", "additional_pay_subsidy_percent" ), CsvColumn("Oppisopimus?", "apprenticeship_program", format_bool), CsvColumn("Arkistoitu?", "archived", format_bool), - CsvDefaultColumn("Hakemusvaihe(UI)", "application_step"), + csv_default_column("Hakemusvaihe(UI)", "application_step"), CsvColumn("Työntekijä-ID", "employee.id", str), CsvColumn("Työntekijän etunimi", "employee.first_name"), CsvColumn("Työntekijän sukunimi", "employee.last_name"), @@ -194,67 +201,73 @@ def CSV_COLUMNS(self): CsvColumn("Työntekijän sähköposti", "employee.email"), CsvColumn("Työntekijän kieli", "employee.employee_language"), CsvColumn("Työntekijän ammattinimike", "employee.job_title"), - CsvDefaultColumn( + csv_default_column( "Työntekijän kuukausipalkka (hakijalta)", "employee.monthly_pay" ), - CsvDefaultColumn( + csv_default_column( "Työntekijän lomaraha (hakijalta)", "employee.vacation_money" ), - CsvDefaultColumn( + csv_default_column( "Työntekijän muut kulut (hakijalta)", "employee.other_expenses" ), - CsvDefaultColumn("Työntekijän työtunnit", "employee.working_hours"), + csv_default_column("Työntekijän työtunnit", "employee.working_hours"), CsvColumn("Työntekijän TES", "employee.collective_bargaining_agreement"), - CsvDefaultColumn("Työntekijän syntymäpäivä", "employee.birthday"), + csv_default_column("Työntekijän syntymäpäivä", "employee.birthday"), CsvColumn( "Työntekijä asuu Helsinkissä?", "employee.is_living_in_helsinki", format_bool, ), - CsvDefaultColumn( - "Helsinki-lisän määrä lopullinen", - "calculation.calculated_benefit_amount", - ), - CsvDefaultColumn("Kuukausipalkka laskelmassa", "calculation.monthly_pay"), - CsvDefaultColumn("Lomaraha laskelmassa", "calculation.vacation_money"), - CsvDefaultColumn("Muut kulut laskelmassa", "calculation.other_expenses"), - CsvDefaultColumn("Laskelman alkupäivä", "calculation.start_date"), - CsvDefaultColumn("Laskelman päättymispäivä", "calculation.end_date"), - CsvDefaultColumn("Käsittelypäivä", "handled_at", format_datetime), - CsvDefaultColumn( - "Valtiotukimaksimi", "calculation.state_aid_max_percentage" + csv_default_column( + "Helsinki-lisän määrä lopullinen", calculated_benefit_amount ), - CsvDefaultColumn( - "Laskelman lopputulos", "calculation.calculated_benefit_amount" + csv_default_column("Kuukausipalkka laskelmassa", "calculation.monthly_pay"), + csv_default_column("Lomaraha laskelmassa", "calculation.vacation_money"), + csv_default_column("Muut kulut laskelmassa", "calculation.other_expenses"), + csv_default_column("Laskelman alkupäivä", "calculation.start_date"), + csv_default_column("Laskelman päättymispäivä", "calculation.end_date"), + csv_default_column("Käsittelypäivä", "handled_at", format_datetime), + csv_default_column( + "Valtiotukimaksimi", "calculation.state_aid_max_percentage" ), - CsvDefaultColumn( + csv_default_column("Laskelman lopputulos", calculated_benefit_amount), + csv_default_column( "Manuaalinen syöttö", "calculation.override_monthly_benefit_amount" ), - CsvDefaultColumn( + csv_default_column( "Manuaalinen syöttö kommentti", "calculation.override_monthly_benefit_amount_comment", ), - CsvDefaultColumn( + csv_default_column( "Myönnetään de minimis -tukena?", "calculation.granted_as_de_minimis_aid", format_bool, default_value=None, ), - CsvDefaultColumn( + csv_default_column( "Kohderyhmätarkistus", "calculation.target_group_check", format_bool, default_value=None, ), - CsvDefaultColumn( + csv_default_column( "Hyväksymisen/hylkäyksen/peruutuksen syy", "latest_decision_comment" ), - CsvDefaultColumn("Päättäjän nimike", "batch.decision_maker_title"), - CsvDefaultColumn("Päättäjän nimi", "batch.decision_maker_name"), - CsvDefaultColumn("Päätöspykälä", "batch.section_of_the_law"), - CsvDefaultColumn("Päätöspäivä", "batch.decision_date"), - CsvDefaultColumn("Asiantarkastajan nimi", "batch.expert_inspector_name"), - CsvDefaultColumn("Asiantarkastajan email", "batch.expert_inspector_email"), + csv_default_column("Päättäjän nimike", "batch.decision_maker_title"), + csv_default_column("Päättäjän nimi", "batch.decision_maker_name"), + csv_default_column("Päätöspykälä", "batch.section_of_the_law"), + csv_default_column("Päätöspäivä", "batch.decision_date"), + csv_default_column( + "Asiantarkastajan nimi Ahjo", "batch.expert_inspector_name" + ), + csv_default_column( + "Asiantarkastajan titteli Ahjo", "batch.expert_inspector_title" + ), + csv_default_column("Asiantarkastajan nimi P2P", "batch.p2p_inspector_name"), + csv_default_column( + "Asiantarkastajan email P2P", "batch.p2p_inspector_email" + ), + csv_default_column("Hyväksyjän nimi P2P", "batch.p2p_checker_name"), # In case there are multiple rows per application, always have the nth ahjo row # in the same column. # The row data here comes from calculation.ahjo_rows[application_row_idx - 1] @@ -266,19 +279,19 @@ def CSV_COLUMNS(self): "Siirrettävä Ahjo-rivi / teksti", current_ahjo_row_field_getter("description_fi"), ), - CsvDefaultColumn( + csv_default_column( "Siirrettävä Ahjo-rivi / määrä eur yht", current_ahjo_row_field_getter("amount"), ), - CsvDefaultColumn( + csv_default_column( "Siirrettävä Ahjo-rivi / määrä eur kk", current_ahjo_row_field_getter("monthly_amount"), ), - CsvDefaultColumn( + csv_default_column( "Siirrettävä Ahjo-rivi / alkupäivä", current_ahjo_row_field_getter("start_date"), ), - CsvDefaultColumn( + csv_default_column( "Siirrettävä Ahjo-rivi / päättymispäivä", current_ahjo_row_field_getter("end_date"), ), @@ -295,19 +308,19 @@ def CSV_COLUMNS(self): f"Ahjo-rivi {idx + 1} / teksti", nested_queryset_attr("ahjo_rows", idx, "description_fi"), ), - CsvDefaultColumn( + csv_default_column( f"Ahjo-rivi {idx + 1} / määrä eur yht", nested_queryset_attr("ahjo_rows", idx, "amount"), ), - CsvDefaultColumn( + csv_default_column( f"Ahjo-rivi {idx + 1} / määrä eur kk", nested_queryset_attr("ahjo_rows", idx, "monthly_amount"), ), - CsvDefaultColumn( + csv_default_column( f"Ahjo-rivi {idx + 1} / alkupäivä", nested_queryset_attr("ahjo_rows", idx, "start_date"), ), - CsvDefaultColumn( + csv_default_column( f"Ahjo-rivi {idx + 1} / päättymispäivä", nested_queryset_attr("ahjo_rows", idx, "end_date"), ), @@ -316,21 +329,21 @@ def CSV_COLUMNS(self): for idx in range(self.MAX_PAY_SUBSIDIES): columns.extend( [ - CsvDefaultColumn( + csv_default_column( f"Palkkatuki {idx + 1} / alkupäivä", nested_queryset_attr("pay_subsidies", idx, "start_date"), ), - CsvDefaultColumn( + csv_default_column( f"Palkkatuki {idx + 1} / päättymispäivä", nested_queryset_attr("pay_subsidies", idx, "end_date"), ), - CsvDefaultColumn( + csv_default_column( f"Palkkatuki {idx + 1} / palkkatukiprosentti", nested_queryset_attr( "pay_subsidies", idx, "pay_subsidy_percent" ), ), - CsvDefaultColumn( + csv_default_column( f"Palkkatuki {idx + 1} / työaikaprosentti", nested_queryset_attr("pay_subsidies", idx, "work_time_percent"), ), @@ -351,11 +364,11 @@ def CSV_COLUMNS(self): f"De minimis {idx + 1} / myöntäjä", nested_queryset_attr("de_minimis_aid_set", idx, "granter"), ), - CsvDefaultColumn( + csv_default_column( f"De minimis {idx + 1} / määrä", nested_queryset_attr("de_minimis_aid_set", idx, "amount"), ), - CsvDefaultColumn( + csv_default_column( f"De minimis {idx + 1} / myönnetty", nested_queryset_attr("de_minimis_aid_set", idx, "granted_at"), ), diff --git a/backend/benefit/applications/tests/factories.py b/backend/benefit/applications/tests/factories.py index 5ffda194ae..09027f5806 100755 --- a/backend/benefit/applications/tests/factories.py +++ b/backend/benefit/applications/tests/factories.py @@ -275,7 +275,7 @@ class Meta: model = Employee -class ApplicationBatchFactory(factory.django.DjangoModelFactory): +class BaseApplicationBatchFactory(factory.django.DjangoModelFactory): proposal_for_decision = AhjoDecision.DECIDED_ACCEPTED application_1 = factory.RelatedFactory( DecidedApplicationFactory, @@ -288,17 +288,27 @@ class ApplicationBatchFactory(factory.django.DjangoModelFactory): factory_related_name="batch", status=factory.SelfAttribute("batch.proposal_for_decision"), ) - decision_maker_title = factory.Faker("job", locale="fi_FI") - decision_maker_name = factory.Faker("name", locale="fi_FI") - section_of_the_law = factory.Faker("word", locale="fi_FI") + + class Meta: + model = ApplicationBatch + + +class ApplicationBatchFactory(BaseApplicationBatchFactory): decision_date = factory.Faker( "date_between_dates", date_start=factory.LazyAttribute(lambda _: date.today() - timedelta(days=30)), date_end=factory.LazyAttribute(lambda _: date.today()), ) - + decision_maker_title = factory.Faker("job", locale="fi_FI") + decision_maker_name = factory.Faker("name", locale="fi_FI") + section_of_the_law = "§1234" expert_inspector_name = factory.Faker("name", locale="fi_FI") expert_inspector_email = factory.Faker("email", locale="fi_FI") + expert_inspector_title = factory.Faker("job", locale="fi_FI") + + p2p_inspector_name = factory.Faker("name", locale="fi_FI") + p2p_inspector_email = factory.Faker("email", locale="fi_FI") + p2p_checker_name = factory.Faker("name", locale="fi_FI") class Meta: model = ApplicationBatch diff --git a/backend/benefit/applications/tests/test_application_batch_api.py b/backend/benefit/applications/tests/test_application_batch_api.py index f28c172cce..f53db2ec46 100755 --- a/backend/benefit/applications/tests/test_application_batch_api.py +++ b/backend/benefit/applications/tests/test_application_batch_api.py @@ -13,6 +13,7 @@ from applications.api.v1.serializers.application import ApplicationBatchSerializer from applications.enums import AhjoDecision, ApplicationBatchStatus, ApplicationStatus +from applications.exceptions import BatchTooManyDraftsError from applications.models import Application, ApplicationBatch from applications.tests.conftest import * # noqa from applications.tests.factories import ( @@ -32,6 +33,9 @@ def get_valid_batch_completion_data(): "decision_date": date.today(), "expert_inspector_name": get_faker().name(), "expert_inspector_title": get_faker().job(), + "p2p_inspector_name": get_faker().name(), + "p2p_inspector_email": get_faker().email(), + "p2p_checker_name": get_faker().name(), } @@ -223,21 +227,40 @@ def test_deassign_applications_from_batch(handler_api_client, application_batch) assert response.status_code == 404 +def test_deassign_applications_from_batch_all(handler_api_client, application_batch): + apps = Application.objects.filter(batch=application_batch) + url = get_batch_detail_url(application_batch, "deassign_applications/") + response = handler_api_client.patch( + url, + { + "application_ids": list(map(lambda app: app.id, apps)), + "batch_id": application_batch.id, + }, + ) + assert response.status_code == 200 + with pytest.raises(ApplicationBatch.DoesNotExist): + application_batch.refresh_from_db() + + @pytest.mark.parametrize( "batch_status,status_code,changed_status", [ (ApplicationBatchStatus.COMPLETED, 400, None), - (ApplicationBatchStatus.SENT_TO_TALPA, 400, None), - (ApplicationBatchStatus.AHJO_REPORT_CREATED, 400, None), + (ApplicationBatchStatus.SENT_TO_TALPA, 200, None), (ApplicationBatchStatus.RETURNED, 400, None), - (ApplicationBatchStatus.DECIDED_ACCEPTED, 400, None), - (ApplicationBatchStatus.DECIDED_REJECTED, 400, None), + (ApplicationBatchStatus.DECIDED_ACCEPTED, 200, None), + (ApplicationBatchStatus.DECIDED_REJECTED, 200, None), (ApplicationBatchStatus.DRAFT, 200, ApplicationBatchStatus.DRAFT), ( ApplicationBatchStatus.AWAITING_AHJO_DECISION, 200, ApplicationBatchStatus.AWAITING_AHJO_DECISION, ), + ( + ApplicationBatchStatus.AHJO_REPORT_CREATED, + 200, + ApplicationBatchStatus.AHJO_REPORT_CREATED, + ), ], ) def test_batch_status_change( @@ -250,6 +273,35 @@ def test_batch_status_change( assert response.data["status"] == changed_status +def test_batch_too_many_drafts(application_batch): + # Create a second batch to get to two batch limit + ApplicationBatchFactory( + status=ApplicationBatchStatus.DRAFT, + proposal_for_decision=ApplicationStatus.REJECTED, + ), + + # Create a batch with different status and try putting it in draft + batch_with_status_change = ApplicationBatchFactory( + status=ApplicationBatchStatus.AWAITING_AHJO_DECISION, + proposal_for_decision=ApplicationStatus.REJECTED, + ) + batch_with_status_change.status = ApplicationBatchStatus.DRAFT + with pytest.raises(BatchTooManyDraftsError): + batch_with_status_change.save() + + # Create more drafts, accepted and rejected, should fail + with pytest.raises(BatchTooManyDraftsError): + ApplicationBatchFactory( + status=ApplicationBatchStatus.DRAFT, + proposal_for_decision=ApplicationStatus.ACCEPTED, + ) + with pytest.raises(BatchTooManyDraftsError): + ApplicationBatchFactory( + status=ApplicationBatchStatus.DRAFT, + proposal_for_decision=ApplicationStatus.REJECTED, + ) + + @pytest.mark.parametrize( "batch_status,delta_months,delta_days", [ diff --git a/backend/benefit/applications/tests/test_application_tasks.py b/backend/benefit/applications/tests/test_application_tasks.py index 4ca508ee2e..7b34688a6f 100755 --- a/backend/benefit/applications/tests/test_application_tasks.py +++ b/backend/benefit/applications/tests/test_application_tasks.py @@ -15,8 +15,8 @@ def test_seed_applications_with_arguments(set_debug_to_true): amount = 5 statuses = ApplicationStatus.values - batch_count = 4 - total_created = (len(ApplicationStatus.values) + batch_count) * amount + batch_count = 6 + total_created = ((len(ApplicationStatus.values) * 2) + batch_count) * amount out = StringIO() call_command("seed", number=amount, stdout=out) diff --git a/backend/benefit/applications/tests/test_applications_api.py b/backend/benefit/applications/tests/test_applications_api.py index 7e5f489e20..27782c5474 100755 --- a/backend/benefit/applications/tests/test_applications_api.py +++ b/backend/benefit/applications/tests/test_applications_api.py @@ -305,7 +305,7 @@ def test_application_single_read_as_applicant( @pytest.mark.parametrize( "status, expected_result", [ - (ApplicationStatus.DRAFT, 404), + (ApplicationStatus.DRAFT, 200), (ApplicationStatus.ADDITIONAL_INFORMATION_NEEDED, 200), (ApplicationStatus.RECEIVED, 200), (ApplicationStatus.HANDLING, 200), @@ -1173,7 +1173,7 @@ def test_application_status_change_as_applicant( @pytest.mark.parametrize( "from_status,to_status,expected_code", [ - (ApplicationStatus.DRAFT, ApplicationStatus.RECEIVED, 404), + (ApplicationStatus.DRAFT, ApplicationStatus.RECEIVED, 200), (ApplicationStatus.RECEIVED, ApplicationStatus.HANDLING, 200), ( ApplicationStatus.HANDLING, @@ -1701,7 +1701,7 @@ def test_pdf_attachment_upload_and_download_as_applicant( @pytest.mark.parametrize( "status,upload_result", [ - (ApplicationStatus.DRAFT, 404), + (ApplicationStatus.DRAFT, 201), (ApplicationStatus.ADDITIONAL_INFORMATION_NEEDED, 201), (ApplicationStatus.HANDLING, 201), (ApplicationStatus.ACCEPTED, 403), @@ -2181,6 +2181,30 @@ def test_handler_application_exlude_batched(handler_api_client): assert len(response.data) == 1 +def test_handler_application_filter_archived(handler_api_client): + apps = [ + DecidedApplicationFactory(), + DecidedApplicationFactory(), + DecidedApplicationFactory(archived=True), + ] + + response = handler_api_client.get( + reverse("v1:handler-application-simplified-application-list") + ) + assert len(response.data) == 2 + for response_app in response.data: + assert response_app["id"] in [str(apps[0].id), str(apps[1].id)] + assert not response_app["archived"] + + response = handler_api_client.get( + reverse("v1:handler-application-simplified-application-list"), + {"filter_archived": "1"}, + ) + assert len(response.data) == 1 + assert response.data[0]["id"] == str(apps[2].id) + assert response.data[0]["archived"] + + def _create_random_applications(): f = faker.Faker() combos = [ diff --git a/backend/benefit/applications/tests/test_applications_report.py b/backend/benefit/applications/tests/test_applications_report.py index 525ba0ce1a..ae73f93112 100644 --- a/backend/benefit/applications/tests/test_applications_report.py +++ b/backend/benefit/applications/tests/test_applications_report.py @@ -300,7 +300,7 @@ def test_pruned_applications_csv_output( pruned_applications_csv_service_with_one_application.get_applications()[0] ) # Assert that there are 15 column headers in the pruned CSV - assert len(csv_lines[0]) == 15 + assert len(csv_lines[0]) == 18 assert csv_lines[0][0] == '"Hakemusnumero"' assert csv_lines[0][1] == '"Työnantajan tyyppi"' @@ -315,11 +315,14 @@ def test_pruned_applications_csv_output( assert csv_lines[0][10] == '"Päättäjän nimi"' assert csv_lines[0][11] == '"Päätöspykälä"' assert csv_lines[0][12] == '"Päätöspäivä"' - assert csv_lines[0][13] == '"Asiantarkastajan nimi"' - assert csv_lines[0][14] == '"Asiantarkastajan email"' + assert csv_lines[0][13] == '"Asiantarkastajan nimi Ahjo"' + assert csv_lines[0][14] == '"Asiantarkastajan titteli Ahjo"' + assert csv_lines[0][15] == '"Asiantarkastajan nimi P2P"' + assert csv_lines[0][16] == '"Asiantarkastajan email P2P"' + assert csv_lines[0][17] == '"Hyväksyjän nimi P2P"' # Assert that there are 15 columns in the pruned CSV - assert len(csv_lines[1]) == 15 + assert len(csv_lines[1]) == 18 assert int(csv_lines[1][0]) == application.application_number assert csv_lines[1][1] == '"Yritys"' @@ -338,7 +341,10 @@ def test_pruned_applications_csv_output( assert csv_lines[1][11] == f'"{application.batch.section_of_the_law}"' assert csv_lines[1][12] == f'"{application.batch.decision_date}"' assert csv_lines[1][13] == f'"{application.batch.expert_inspector_name}"' - assert csv_lines[1][14] == f'"{application.batch.expert_inspector_email}"' + assert csv_lines[1][14] == f'"{application.batch.expert_inspector_title}"' + assert csv_lines[1][15] == f'"{application.batch.p2p_inspector_name}"' + assert csv_lines[1][16] == f'"{application.batch.p2p_inspector_email}"' + assert csv_lines[1][17] == f'"{application.batch.p2p_checker_name}"' def test_applications_csv_output(applications_csv_service): # noqa: C901 diff --git a/backend/benefit/applications/tests/test_models.py b/backend/benefit/applications/tests/test_models.py index 9f5524f8b9..d10286a028 100755 --- a/backend/benefit/applications/tests/test_models.py +++ b/backend/benefit/applications/tests/test_models.py @@ -1,8 +1,11 @@ +from datetime import date + import pytest from applications.enums import AhjoDecision, ApplicationBatchStatus from applications.exceptions import BatchCompletionRequiredFieldsError from applications.models import Application, ApplicationBatch, Employee +from applications.tests.factories import BaseApplicationBatchFactory from applications.tests.test_application_batch_api import ( fill_as_valid_batch_completion_and_save, ) @@ -46,7 +49,15 @@ def test_application_batch_ahjo_decision(application_batch, status, expected_res ApplicationBatchStatus.DECIDED_ACCEPTED, ApplicationBatchStatus.DECIDED_REJECTED, ]: + # need to create a new batch because factory has already created valid fields using ApplicationBatchFactory + application_batch.delete() + application_batch = BaseApplicationBatchFactory( + status=ApplicationBatchStatus.DRAFT, + proposal_for_decision=status, + decision_date=date.today(), + ) with pytest.raises(BatchCompletionRequiredFieldsError): + application_batch.status = status application_batch.save() fill_as_valid_batch_completion_and_save(application_batch) else: @@ -78,7 +89,16 @@ def test_application_batch_modified(application_batch, status, expected_result): ApplicationBatchStatus.DECIDED_ACCEPTED, ApplicationBatchStatus.DECIDED_REJECTED, ]: + # need to create a new batch because factory has already created valid fields using ApplicationBatchFactory + application_batch.delete() + application_batch = BaseApplicationBatchFactory( + status=ApplicationBatchStatus.DRAFT, + proposal_for_decision=status, + decision_date=date.today(), + ) + with pytest.raises(BatchCompletionRequiredFieldsError): + application_batch.status = status application_batch.save() fill_as_valid_batch_completion_and_save(application_batch) else: diff --git a/backend/benefit/calculator/api/v1/serializers.py b/backend/benefit/calculator/api/v1/serializers.py index d74be3daa8..22778e907e 100644 --- a/backend/benefit/calculator/api/v1/serializers.py +++ b/backend/benefit/calculator/api/v1/serializers.py @@ -1,3 +1,5 @@ +from typing import Union + from dateutil.relativedelta import relativedelta from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -9,6 +11,7 @@ CalculationRow, PaySubsidy, PreviousBenefit, + STATE_AID_MAX_PERCENTAGE_CHOICES, TrainingCompensation, ) from users.api.v1.serializers import UserSerializer @@ -259,6 +262,11 @@ def _are_dates_required(self): and not self._is_manual_mode() ) + def _is_invalid_state_aid_max(self, state_aid_max_input: Union[None, int]) -> bool: + return state_aid_max_input is None or state_aid_max_input not in [ + choice[0] for choice in STATE_AID_MAX_PERCENTAGE_CHOICES + ] + def validate(self, data): request = self.context.get("request") if request is None: @@ -272,6 +280,17 @@ def validate(self, data): raise serializers.ValidationError( {"end_date": _("End date cannot be empty")} ) + state_aid_max_input = request.data["calculation"][ + "state_aid_max_percentage" + ] + if self._is_invalid_state_aid_max(state_aid_max_input): + raise serializers.ValidationError( + { + "state_aid_max_percentage": _( + "State aid maximum percentage cannot be empty" + ) + } + ) return data class Meta: diff --git a/backend/benefit/calculator/tests/test_calculator_api.py b/backend/benefit/calculator/tests/test_calculator_api.py index 26ab9a0e55..5a770d1354 100644 --- a/backend/benefit/calculator/tests/test_calculator_api.py +++ b/backend/benefit/calculator/tests/test_calculator_api.py @@ -1,5 +1,6 @@ import copy import decimal +from datetime import datetime, timedelta from unittest import mock import factory @@ -41,6 +42,26 @@ def _set_two_pay_subsidies_with_empty_dates(data: dict) -> dict: return data +def _set_two_pay_subsidies_with_non_empty_dates(data: dict) -> dict: + start = datetime.now() + timedelta(days=1) + end = datetime.now() + timedelta(weeks=8) + data["pay_subsidies"] = [ + { + "start_date": start.strftime("%Y-%m-%d"), + "end_date": end.strftime("%Y-%m-%d"), + "pay_subsidy_percent": 100, + "work_time_percent": 40, + }, + { + "start_date": start.strftime("%Y-%m-%d"), + "end_date": end.strftime("%Y-%m-%d"), + "pay_subsidy_percent": 40, + "work_time_percent": 40, + }, + ] + return data + + def test_application_retrieve_calculation_as_handler( handler_api_client, handling_application ): @@ -395,6 +416,36 @@ def test_ignore_pay_subsidy_dates_when_application_is_received( assert response.status_code == 200 +def test_subsidies_validation_when_state_aid_max_percentage_is_not_set( + handler_api_client, + mock_get_organisation_roles_and_create_company, +): + with factory.Faker.override_default_locale("fi_FI"): + handling_application = ReceivedApplicationFactory( + status=ApplicationStatus.HANDLING, + apprenticeship_program=False, + benefit_type=BenefitType.SALARY_BENEFIT, + company=mock_get_organisation_roles_and_create_company, + pay_subsidy_granted=True, + pay_subsidy_percent=100, + additional_pay_subsidy_percent=40, + ) + + data = HandlerApplicationSerializer(handling_application).data + _set_two_pay_subsidies_with_non_empty_dates(data) + assert data["calculation"]["state_aid_max_percentage"] is None + + response = handler_api_client.put( + get_handler_detail_url(handling_application), data + ) + + assert response.status_code == 400 + assert "pay_subsidies" in response.json() + assert { + "state_aid_max_percentage": ["State aid maximum percentage cannot be empty"] + } in response.json()["pay_subsidies"] + + @pytest.mark.parametrize("benefit_type", BenefitType.values) @pytest.mark.parametrize( "override_monthly_benefit_amount,override_monthly_benefit_amount_comment", @@ -568,7 +619,7 @@ def test_assign_handler(handler_api_client, received_application): (ApplicationStatus.ACCEPTED, 400), (ApplicationStatus.CANCELLED, 400), (ApplicationStatus.REJECTED, 400), - (ApplicationStatus.DRAFT, 404), + (ApplicationStatus.DRAFT, 400), ], ) def test_assign_handler_invalid_status( diff --git a/backend/benefit/companies/api/v1/serializers.py b/backend/benefit/companies/api/v1/serializers.py index 3dab580514..3fc18068dd 100644 --- a/backend/benefit/companies/api/v1/serializers.py +++ b/backend/benefit/companies/api/v1/serializers.py @@ -29,3 +29,8 @@ class Meta: @extend_schema_field(serializers.ChoiceField(choices=OrganizationType.choices)) def get_organization_type(self, obj): return OrganizationType.resolve_organization_type(obj.company_form_code) + + +class CompanySearchSerializer(serializers.Serializer): + name = serializers.CharField(max_length=200) + business_id = serializers.CharField(max_length=20) diff --git a/backend/benefit/companies/api/v1/views.py b/backend/benefit/companies/api/v1/views.py index b99fdf9deb..b2321313a3 100644 --- a/backend/benefit/companies/api/v1/views.py +++ b/backend/benefit/companies/api/v1/views.py @@ -3,40 +3,47 @@ from django.conf import settings from django.db import transaction from django.http import HttpRequest -from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes +from django.utils.translation import gettext_lazy as __ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, OpenApiParameter from requests.exceptions import HTTPError from rest_framework import status from rest_framework.response import Response +from rest_framework.serializers import ValidationError from rest_framework.views import APIView +from stdnum.fi import ytunnus from common.permissions import BFIsAuthenticated, TermsOfServiceAccepted -from companies.api.v1.serializers import CompanySerializer +from companies.api.v1.serializers import CompanySearchSerializer, CompanySerializer from companies.models import Company -from companies.services import get_or_create_organisation_with_business_id +from companies.services import ( + get_or_create_organisation_with_business_id, + search_organisations, +) from companies.tests.data.company_data import get_dummy_company_data from shared.oidc.utils import get_organization_roles LOGGER = logging.getLogger(__name__) -class GetCompanyView(APIView): - """ - API View to retrieve company info using the YTJ API integration. - """ +class GetUsersOrganizationView(APIView): + """API View to retrieve organization that the users belongs to.""" permission_classes = [BFIsAuthenticated, TermsOfServiceAccepted] @property def ytj_api_error(self): return Response( - "YTJ API is under heavy load or no company found with the given business id", + __( + "YTJ API is under heavy load or no company found with the given business id" + ), status.HTTP_404_NOT_FOUND, ) @property def organization_roles_error(self): return Response( - "Unable to fetch organization roles from eauthorizations API", + __("Unable to fetch organization roles from eauthorizations API"), status.HTTP_401_UNAUTHORIZED, ) @@ -44,7 +51,7 @@ def api_usage_http_error(self, message): return Response(message, status.HTTP_400_BAD_REQUEST) @transaction.atomic - def get_mock(self, request: HttpRequest, format: str = None) -> Response: + def get_mock(self, request: HttpRequest, format: str = "") -> Response: # This mocked get method will be used for testing purposes for the frontend. # If the dummy company does not already exist in the database, create it, # so that the validation logic in the API works. @@ -72,7 +79,7 @@ def get_mock(self, request: HttpRequest, format: str = None) -> Response: description="Retrieve company information from YTJ/other API", ) @transaction.atomic - def get(self, request: HttpRequest, format: str = None) -> Response: + def get(self, request: HttpRequest, format: str = "") -> Response: if settings.NEXT_PUBLIC_MOCK_FLAG: return self.get_mock(request, format) @@ -82,6 +89,11 @@ def get(self, request: HttpRequest, format: str = None) -> Response: return self.organization_roles_error business_id = organization_roles.get("identifier") + if not business_id: + return Response( + "No business id found for the user", + status.HTTP_404_NOT_FOUND, + ) try: company = get_or_create_organisation_with_business_id(business_id) except HTTPError: @@ -100,9 +112,39 @@ def get(self, request: HttpRequest, format: str = None) -> Response: ) ) return Response( - "Could not handle the response from Palveluväylä and YRTTI API", + __("Could not handle the response from Palveluväylä and YRTTI API"), status.HTTP_500_INTERNAL_SERVER_ERROR, ) company_data = CompanySerializer(company).data return Response(company_data) + + +class SearchOrganisationsView(APIView): + @extend_schema( + parameters=[ + OpenApiParameter("name", OpenApiTypes.STR, OpenApiParameter.PATH), + ], + responses=CompanySearchSerializer(many=True), + description="Search organisations", + ) + @transaction.atomic + def get(self, _: HttpRequest, name: str) -> Response: + results = search_organisations(name) + return Response(CompanySearchSerializer(results, many=True).data) + + +class GetOrganisationByIdView(APIView): + @extend_schema( + parameters=[ + OpenApiParameter("business_id", OpenApiTypes.STR, OpenApiParameter.PATH), + ], + responses=CompanySerializer, + description="Get organisation by business id", + ) + @transaction.atomic + def get(self, _: HttpRequest, business_id: str) -> Response: + if not ytunnus.is_valid(business_id): + raise ValidationError(__("Social security number invalid")) + company = get_or_create_organisation_with_business_id(business_id) + return Response(CompanySerializer(company).data) diff --git a/backend/benefit/companies/services.py b/backend/benefit/companies/services.py index 8ad8e9488d..0f1afe68f2 100644 --- a/backend/benefit/companies/services.py +++ b/backend/benefit/companies/services.py @@ -1,4 +1,7 @@ +from django.conf import settings +from django.http import Http404 from requests import HTTPError +from rest_framework.exceptions import APIException from common.utils import update_object from companies.models import Company @@ -11,6 +14,10 @@ def get_or_create_company_using_company_data(company_data: dict) -> Company: Get or create a company instance using a dict of the company data. Then update the latest YTJ data to the Company model instance """ + if not company_data: + raise APIException( + "Could not handle the response from Palveluväylä and YRTTI API" + ) business_id = company_data.pop("business_id") company, _ = Company.objects.get_or_create( business_id=business_id, @@ -27,33 +34,42 @@ def get_or_create_organisation_with_business_id(business_id: str) -> Company: business_id ) except HTTPError: - # Using ServiceBus, it's guarantee to find company, but not association, so if the first request return 404, - # try again with YRTTI API organisation = get_or_create_association_with_business_id(business_id) - return organisation + if organisation: + return organisation + raise Http404("Organisation not found") def get_or_create_organisation_with_business_id_via_service_bus( business_id: str, ) -> Company: - """ - Create a company instance using the Palveluväylä integration. - """ - sb_client = ServiceBusClient() - - sb_data = sb_client.get_organisation_info_with_business_id(business_id) - company_data = sb_client.get_organisation_data_from_service_bus_data(sb_data) + """Create a company instance using the Palveluväylä integration.""" + sb_client = ServiceBusClient() + company_data = sb_client.get_organisation_info_with_business_id(business_id) return get_or_create_company_using_company_data(company_data) def get_or_create_association_with_business_id(business_id: str) -> Company: - """ - Create a company instance using the YTJ integration. - """ + """Create a company instance using the Yrtti integration.""" + yrtti_client = YRTTIClient() + association_data = yrtti_client.get_association_info_with_business_id(business_id) + return get_or_create_company_using_company_data(association_data) - yrtti_data = yrtti_client.get_association_info_with_business_id(business_id) - company_data = yrtti_client.get_association_data_from_yrtti_data(yrtti_data) - return get_or_create_company_using_company_data(company_data) +def search_organisations(search_term: str) -> list[dict]: + """Search for organisations Service Bus (YTJ) and YRTTI and merge results.""" + + sb_client = ServiceBusClient() + company_data = sb_client.search_companies(search_term) + association_data = [] + # Option to disable YRTTI as their test api has some difficulties + if not settings.YRTTI_DISABLE: + yrtti_client = YRTTIClient() + association_data = yrtti_client.search_associations(search_term) + merged_results = company_data + association_data + results_without_duplicates = [ + dict(unique) for unique in {tuple(result.items()) for result in merged_results} + ] + return results_without_duplicates diff --git a/backend/benefit/companies/tests/test_api.py b/backend/benefit/companies/tests/test_api.py index 024851c4c4..068a01cda0 100644 --- a/backend/benefit/companies/tests/test_api.py +++ b/backend/benefit/companies/tests/test_api.py @@ -17,9 +17,15 @@ from shared.service_bus.enums import YtjOrganizationCode from terms.tests.factories import TermsOfServiceApprovalFactory +SERVICE_BUS_INFO_PATH = f"{settings.SERVICE_BUS_BASE_URL}/GetCompany" +YRTTI_BASIC_INFO_PATH = f"{settings.YRTTI_BASE_URL}/BasicInfo" + def get_company_api_url(business_id=""): - return "/v1/company/{id}".format(id=business_id) + url = "/v1/company/" + if business_id: + url = f"{url}get/{business_id}/" + return url def set_up_ytj_mock_requests( @@ -69,16 +75,20 @@ def test_get_mock_company_results_in_error( def test_get_company_from_service_bus_invalid_response( api_client, requests_mock, mock_get_organisation_roles_and_create_company ): + business_id = DUMMY_SERVICE_BUS_RESPONSE["GetCompanyResult"]["Company"][ + "BusinessId" + ] response = deepcopy(DUMMY_SERVICE_BUS_RESPONSE) response["GetCompanyResult"]["Company"]["PostalAddress"] = {} - matcher = re.compile(re.escape(settings.SERVICE_BUS_INFO_PATH)) + matcher = re.compile(re.escape(SERVICE_BUS_INFO_PATH)) requests_mock.post(matcher, json=response) - response = api_client.get(get_company_api_url()) + response = api_client.get(get_company_api_url(business_id)) assert response.status_code == 500 assert ( - response.data == "Could not handle the response from Palveluväylä and YRTTI API" + response.data["detail"] + == "Could not handle the response from Palveluväylä and YRTTI API" ) @@ -89,16 +99,15 @@ def test_get_organisation_from_service_bus( requests_mock, mock_get_organisation_roles_and_create_company, ): - matcher = re.compile(re.escape(settings.SERVICE_BUS_INFO_PATH)) + matcher = re.compile(re.escape(SERVICE_BUS_INFO_PATH)) requests_mock.post(matcher, json=DUMMY_SERVICE_BUS_RESPONSE) - response = api_client.get(get_company_api_url()) + business_id = DUMMY_SERVICE_BUS_RESPONSE["GetCompanyResult"]["Company"][ + "BusinessId" + ] + response = api_client.get(get_company_api_url(business_id)) assert response.status_code == 200 - company = Company.objects.get( - business_id=DUMMY_SERVICE_BUS_RESPONSE["GetCompanyResult"]["Company"][ - "BusinessId" - ] - ) + company = Company.objects.get(business_id=business_id) company_data = CompanySerializer(company).data assert response.data == company_data assert ( @@ -117,7 +126,7 @@ def test_get_organisation_from_service_bus_missing_business_line( ): dummy_copy = deepcopy(DUMMY_SERVICE_BUS_RESPONSE) dummy_copy["GetCompanyResult"]["Company"]["BusinessLine"] = None - matcher = re.compile(re.escape(settings.SERVICE_BUS_INFO_PATH)) + matcher = re.compile(re.escape(SERVICE_BUS_INFO_PATH)) requests_mock.post(matcher, json=dummy_copy) response = api_client.get(get_company_api_url()) assert response.status_code == 200 @@ -149,16 +158,16 @@ def test_get_company_from_yrtti( company=mock_get_organisation_roles_and_create_association, terms=terms_of_service, ) - matcher = re.compile(re.escape(settings.SERVICE_BUS_INFO_PATH)) + business_id = DUMMY_YRTTI_RESPONSE["BasicInfoResponse"]["BusinessId"] + matcher = re.compile(re.escape(SERVICE_BUS_INFO_PATH)) requests_mock.post(matcher, text="Error", status_code=404) - matcher = re.compile(re.escape(settings.YRTTI_BASIC_INFO_PATH)) + matcher = re.compile(re.escape(YRTTI_BASIC_INFO_PATH)) requests_mock.post(matcher, json=DUMMY_YRTTI_RESPONSE) - response = api_client.get(get_company_api_url()) + response = api_client.get(get_company_api_url(business_id)) + print(response.data) assert response.status_code == 200 - company = Company.objects.get( - business_id=DUMMY_YRTTI_RESPONSE["BasicInfoResponse"]["BusinessId"] - ) + company = Company.objects.get(business_id=business_id) company_data = CompanySerializer(company).data assert response.data == company_data assert ( @@ -175,9 +184,9 @@ def test_get_company_from_yrtti( def test_get_company_from_service_bus_and_yrtti_results_in_error( api_client, requests_mock, mock_get_organisation_roles_and_create_company ): - matcher = re.compile(re.escape(settings.SERVICE_BUS_INFO_PATH)) + matcher = re.compile(re.escape(SERVICE_BUS_INFO_PATH)) requests_mock.post(matcher, text="Error", status_code=404) - matcher = re.compile(re.escape(settings.YRTTI_BASIC_INFO_PATH)) + matcher = re.compile(re.escape(YRTTI_BASIC_INFO_PATH)) requests_mock.post(matcher, text="Error", status_code=404) # Delete company so that API cannot return object from DB mock_get_organisation_roles_and_create_company.delete() @@ -189,7 +198,7 @@ def test_get_company_from_service_bus_and_yrtti_results_in_error( def test_get_company_from_service_bus_and_yrtti_with_fallback_data( api_client, requests_mock, mock_get_organisation_roles_and_create_company ): - matcher = re.compile(re.escape(settings.SERVICE_BUS_INFO_PATH)) + matcher = re.compile(re.escape(SERVICE_BUS_INFO_PATH)) requests_mock.post(matcher, json=DUMMY_SERVICE_BUS_RESPONSE) response = api_client.get(get_company_api_url()) @@ -198,9 +207,9 @@ def test_get_company_from_service_bus_and_yrtti_with_fallback_data( assert Company.objects.count() == 1 # Now assuming request to YTJ & YRTTI doesn't return any data - matcher = re.compile(re.escape(settings.SERVICE_BUS_INFO_PATH)) + matcher = re.compile(re.escape(SERVICE_BUS_INFO_PATH)) requests_mock.post(matcher, text="Error", status_code=404) - matcher = re.compile(re.escape(settings.YRTTI_BASIC_INFO_PATH)) + matcher = re.compile(re.escape(YRTTI_BASIC_INFO_PATH)) requests_mock.post(matcher, text="Error", status_code=404) response = api_client.get(get_company_api_url()) diff --git a/backend/benefit/docker-entrypoint.sh b/backend/benefit/docker-entrypoint.sh index d6e1b17a16..040c0d3c0b 100755 --- a/backend/benefit/docker-entrypoint.sh +++ b/backend/benefit/docker-entrypoint.sh @@ -16,7 +16,9 @@ fi if [[ "$LOAD_FIXTURES" = "1" ]]; then echo "Loading fixtures..." ./manage.py loaddata groups.json - ./manage.py loaddata default_terms.json + if [[ "$LOAD_DEFAULT_TERMS" = "1" ]]; then + ./manage.py loaddata default_terms.json + fi ./manage.py set_group_permissions fi diff --git a/backend/benefit/helsinkibenefit/settings.py b/backend/benefit/helsinkibenefit/settings.py index 4193add1cd..bb927ad891 100644 --- a/backend/benefit/helsinkibenefit/settings.py +++ b/backend/benefit/helsinkibenefit/settings.py @@ -2,6 +2,7 @@ import environ import sentry_sdk +from corsheaders.defaults import default_headers from django.utils.translation import gettext_lazy as _ from sentry_sdk.integrations.django import DjangoIntegration @@ -48,7 +49,7 @@ CSRF_COOKIE_DOMAIN=(str, "localhost"), CSRF_TRUSTED_ORIGINS=(list, ["localhost:3000", "localhost:3100"]), CSRF_COOKIE_NAME=(str, "yjdhcsrftoken"), - YTJ_BASE_URL=(str, "http://avoindata.prh.fi/opendata/tr/v1"), + YTJ_BASE_URL=(str, "https://avoindata.prh.fi"), YTJ_TIMEOUT=(int, 30), # Source: YTJ-rajapinnan koodiston kuvaus, available at https://liityntakatalogi.suomi.fi/dataset/xroadytj-services # file: suomi_fi_palveluvayla_ytj_rajapinta_koodistot_v1_4.xlsx @@ -137,27 +138,25 @@ ENABLE_DEBUG_ENV=(bool, False), TALPA_ROBOT_AUTH_CREDENTIAL=(str, "username:password"), DISABLE_TOS_APPROVAL_CHECK=(bool, False), - YRTTI_BASIC_INFO_PATH=( + YRTTI_BASE_URL=( str, - "https://yrtti-integration-test.agw.arodevtest.hel.fi/api/BasicInfo", + "https://yrtti-integration-test.agw.arodevtest.hel.fi/api", ), YRTTI_AUTH_USERNAME=(str, "sample_username"), YRTTI_AUTH_PASSWORD=(str, "sample_password"), YRTTI_TIMEOUT=(int, 30), - SERVICE_BUS_INFO_PATH=( + YRTTI_SEARCH_LIMIT=(int, 10), + YRTTI_DISABLE=(bool, False), + SERVICE_BUS_BASE_URL=( str, - "https://ytj-integration-test.agw.arodevtest.hel.fi/api/GetCompany", + "https://ytj-integration-test.agw.arodevtest.hel.fi/api", ), SERVICE_BUS_AUTH_USERNAME=(str, "sample_username"), SERVICE_BUS_AUTH_PASSWORD=(str, "sample_password"), SERVICE_BUS_TIMEOUT=(int, 30), + SERVICE_BUS_SEARCH_LIMIT=(int, 10), GDPR_API_QUERY_SCOPE=(str, "helsinkibenefit.gdprquery"), GDPR_API_DELETE_SCOPE=(str, "helsinkibenefit.gdprdelete"), - USE_S3=(bool, False), - S3_ENDPOINT_URL=(str, ""), - S3_ACCESS_KEY_ID=(str, ""), - S3_SECRET_ACCESS_KEY=(str, ""), - S3_STORAGE_BUCKET_NAME=(str, ""), ) if os.path.exists(env_file): env.read_env(env_file) @@ -405,8 +404,12 @@ OIDC_OP_JWKS_ENDPOINT = f"{OIDC_OP_BASE_URL}/jwks" OIDC_OP_LOGOUT_ENDPOINT = f"{OIDC_OP_BASE_URL}/end-session" OIDC_OP_LOGOUT_CALLBACK_URL = env.str("OIDC_OP_LOGOUT_CALLBACK_URL") + # Language selection is done with accept-language header in this project -OIDC_DISABLE_LANGUAGE_COOKIE = True +# UPDATE: 2023-08-07 +# Didn't seem to be working correctly with EAUTH forwards and landing back to callback url +# Changing this to False as nextjs's language autodetect seems to be at set to false too +OIDC_DISABLE_LANGUAGE_COOKIE = False LOGIN_REDIRECT_URL = env.str("LOGIN_REDIRECT_URL") LOGIN_REDIRECT_URL_FAILURE = env.str("LOGIN_REDIRECT_URL_FAILURE") @@ -460,14 +463,17 @@ TALPA_ROBOT_AUTH_CREDENTIAL = env("TALPA_ROBOT_AUTH_CREDENTIAL") YRTTI_TIMEOUT = env("YRTTI_TIMEOUT") -YRTTI_BASIC_INFO_PATH = env("YRTTI_BASIC_INFO_PATH") +YRTTI_BASE_URL = env("YRTTI_BASE_URL") YRTTI_AUTH_USERNAME = env("YRTTI_AUTH_USERNAME") YRTTI_AUTH_PASSWORD = env("YRTTI_AUTH_PASSWORD") +YRTTI_SEARCH_LIMIT = env("YRTTI_SEARCH_LIMIT") +YRTTI_DISABLE = env("YRTTI_DISABLE") SERVICE_BUS_TIMEOUT = env("SERVICE_BUS_TIMEOUT") -SERVICE_BUS_INFO_PATH = env("SERVICE_BUS_INFO_PATH") +SERVICE_BUS_BASE_URL = env("SERVICE_BUS_BASE_URL") SERVICE_BUS_AUTH_USERNAME = env("SERVICE_BUS_AUTH_USERNAME") SERVICE_BUS_AUTH_PASSWORD = env("SERVICE_BUS_AUTH_PASSWORD") +SERVICE_BUS_SEARCH_LIMIT = env("SERVICE_BUS_SEARCH_LIMIT") HANDLERS_GROUP_NAME = "Application handlers" @@ -483,13 +489,8 @@ code = compile(fp.read(), local_settings_path, "exec") exec(code, globals(), locals()) -# S3 settings - -USE_S3 = env("USE_S3") - -if USE_S3: - AWS_S3_ENDPOINT_URL = env("S3_ENDPOINT_URL") - DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" - AWS_ACCESS_KEY_ID = env("S3_ACCESS_KEY_ID") - AWS_SECRET_ACCESS_KEY = env("S3_SECRET_ACCESS_KEY") - AWS_STORAGE_BUCKET_NAME = env("S3_STORAGE_BUCKET_NAME") +CORS_ALLOW_HEADERS = ( + *default_headers, + "baggage", + "sentry-trace", +) diff --git a/backend/benefit/helsinkibenefit/urls.py b/backend/benefit/helsinkibenefit/urls.py index 19a0e69d5c..0e970d750a 100644 --- a/backend/benefit/helsinkibenefit/urls.py +++ b/backend/benefit/helsinkibenefit/urls.py @@ -14,7 +14,11 @@ from applications.api.v1 import application_batch_views, views as application_views from calculator.api.v1 import views as calculator_views from common.debug_util import debug_env -from companies.api.v1.views import GetCompanyView +from companies.api.v1.views import ( + GetOrganisationByIdView, + GetUsersOrganizationView, + SearchOrganisationsView, +) from messages.views import ( ApplicantMessageViewSet, HandlerMessageViewSet, @@ -61,7 +65,9 @@ path("v1/", include(applicant_app_router.urls)), path("v1/", include(handler_app_router.urls)), path("v1/terms/approve_terms_of_service/", ApproveTermsOfServiceView.as_view()), - path("v1/company/", GetCompanyView.as_view()), + path("v1/company/", GetUsersOrganizationView.as_view()), + path("v1/company/search//", SearchOrganisationsView.as_view()), + path("v1/company/get//", GetOrganisationByIdView.as_view()), path("v1/users/me/", CurrentUserView.as_view()), path("oidc/", include("shared.oidc.urls")), path("oauth2/", include("shared.azure_adfs.urls")), diff --git a/backend/benefit/locale/en/LC_MESSAGES/django.po b/backend/benefit/locale/en/LC_MESSAGES/django.po index 84978a76f6..748d9dbfd5 100644 --- a/backend/benefit/locale/en/LC_MESSAGES/django.po +++ b/backend/benefit/locale/en/LC_MESSAGES/django.po @@ -513,6 +513,9 @@ msgstr "" msgid "State aid maximum %" msgstr "" +msgid "State aid maximum percentage cannot be empty" +msgstr "" + msgid "Pay subsidy/month" msgstr "" diff --git a/backend/benefit/locale/fi/LC_MESSAGES/django.po b/backend/benefit/locale/fi/LC_MESSAGES/django.po index 4b431a3521..02f9570f82 100644 --- a/backend/benefit/locale/fi/LC_MESSAGES/django.po +++ b/backend/benefit/locale/fi/LC_MESSAGES/django.po @@ -512,6 +512,9 @@ msgstr "Aloituspäivä ei voi olla tyhjä" msgid "End date cannot be empty" msgstr "Päättymispäivä ei voi olla tyhjä" +msgid "State aid maximum percentage cannot be empty" +msgstr "Valtiontuen maksimimäärä ei voi olla tyhjä ja sen täytyy olla yksi alasvetovalikon arvoista" + msgid "Description row, amount ignored" msgstr "Kuvausrivi, rahamäärä ohitettu" diff --git a/backend/benefit/locale/sv/LC_MESSAGES/django.po b/backend/benefit/locale/sv/LC_MESSAGES/django.po index 4066afbf19..2ed728b6df 100644 --- a/backend/benefit/locale/sv/LC_MESSAGES/django.po +++ b/backend/benefit/locale/sv/LC_MESSAGES/django.po @@ -518,6 +518,9 @@ msgstr "Lönekostnader" msgid "State aid maximum %" msgstr "Maximalt statsunderstöd %" +msgid "State aid maximum percentage cannot be empty" +msgstr "Maximalt statsunderstöd får inte vara tom" + msgid "Pay subsidy/month" msgstr "Lönesubvention/månad" diff --git a/backend/benefit/requirements.in b/backend/benefit/requirements.in index 22f1ac69d0..9a34aa8507 100644 --- a/backend/benefit/requirements.in +++ b/backend/benefit/requirements.in @@ -1,6 +1,5 @@ -e file:../shared babel -boto3 django-cors-headers django-environ django-extensions diff --git a/backend/benefit/requirements.txt b/backend/benefit/requirements.txt index bea262294e..0a1fdc7808 100644 --- a/backend/benefit/requirements.txt +++ b/backend/benefit/requirements.txt @@ -16,12 +16,6 @@ azure-storage-blob==12.15.0 # via django-storages babel==2.11.0 # via -r requirements.in -boto3==1.26.78 - # via -r requirements.in -botocore==1.29.78 - # via - # boto3 - # s3transfer cachetools==5.3.0 # via django-helusers certifi==2022.12.7 @@ -135,10 +129,6 @@ isodate==0.6.1 # via azure-storage-blob jinja2==3.1.2 # via -r requirements.in -jmespath==1.0.1 - # via - # boto3 - # botocore josepy==1.13.0 # via mozilla-django-oidc jsonschema==4.17.3 @@ -184,7 +174,6 @@ pysaml2==7.4.1 python-dateutil==2.8.2 # via # -r requirements.in - # botocore # faker # pysaml2 python-jose==3.3.0 @@ -214,8 +203,6 @@ requests==2.28.2 # pysaml2 rsa==4.9 # via python-jose -s3transfer==0.6.0 - # via boto3 sentry-sdk==1.15.0 # via -r requirements.in six==1.16.0 @@ -239,7 +226,6 @@ uritemplate==4.1.1 # drf-spectacular urllib3==1.26.14 # via - # botocore # django-auth-adfs # elasticsearch # requests diff --git a/backend/benefit/terms/enums.py b/backend/benefit/terms/enums.py index 35f7d6681c..8a4b44fc85 100644 --- a/backend/benefit/terms/enums.py +++ b/backend/benefit/terms/enums.py @@ -7,3 +7,6 @@ class TermsType(models.TextChoices): APPLICANT_TERMS = "applicant_terms", _( "Terms of application - show at application submit" ) + HANDLER_TERMS = "handler_terms", _( + "Terms of application for handler - show at application submit for handler" + ) diff --git a/backend/benefit/terms/fixtures/default_terms.json b/backend/benefit/terms/fixtures/default_terms.json index d14162d9d2..b9a89db98a 100644 --- a/backend/benefit/terms/fixtures/default_terms.json +++ b/backend/benefit/terms/fixtures/default_terms.json @@ -88,5 +88,70 @@ "created_at": "2021-01-01T00:00:00+02:00", "modified_at": "2021-01-01T00:00:00+02:00" } + }, + { + "model": "terms.terms", + "pk": "c89bac1e-aa94-4d96-b622-9a86b396b687", + "fields": { + "terms_type": "handler_terms", + "effective_from": "2021-01-01", + "terms_pdf_fi": "/var/media/please_upload_actual_pdf_manually.pdf", + "terms_pdf_sv": "/var/media/please_upload_actual_pdf_manually.pdf", + "terms_pdf_en": "/var/media/please_upload_actual_pdf_manually.pdf", + "created_at": "2021-01-01T00:00:00+02:00", + "modified_at": "2021-01-01T00:00:00+02:00" + } + }, + { + "model": "terms.applicantconsent", + "pk": "50365185-3cd7-4fd4-b11a-1ce43e66b601", + "fields": { + "terms": "c89bac1e-aa94-4d96-b622-9a86b396b687", + "text_fi": "Hakija on hyväksynyt ehdot", + "text_sv": "translation TBD", + "text_en": "translation TBD", + "ordering": 0, + "created_at": "2021-01-01T00:00:00+02:00", + "modified_at": "2021-01-01T00:00:00+02:00" + } + }, + { + "model": "terms.applicantconsent", + "pk": "66fd80cc-e362-4251-9252-90998e07bb76", + "fields": { + "terms": "c89bac1e-aa94-4d96-b622-9a86b396b687", + "text_fi": "Hakija vakuuttaa, että hänen ilmoittamansa pankkitili on organisaation virallinen tili, hänellä on oikeus asioida organisaation puolesta ja organisaatiolla ei ole verovelkoja.", + "text_sv": "translation TBD", + "text_en": "translation TBD", + "ordering": 1, + "created_at": "2021-01-01T00:00:00+02:00", + "modified_at": "2021-01-01T00:00:00+02:00" + } + }, + { + "model": "terms.applicantconsent", + "pk": "7832d5e1-7f85-4d8d-9a66-4df18b626925", + "fields": { + "terms": "c89bac1e-aa94-4d96-b622-9a86b396b687", + "text_fi": "Työnantaja on allekirjoittanut hakemuksen", + "text_sv": "translation TBD", + "text_en": "translation TBD", + "ordering": 2, + "created_at": "2021-01-01T00:00:00+02:00", + "modified_at": "2021-01-01T00:00:00+02:00" + } + }, + { + "model": "terms.applicantconsent", + "pk": "f6fed789-78d8-43bb-a798-652fa0af9287", + "fields": { + "terms": "c89bac1e-aa94-4d96-b622-9a86b396b687", + "text_fi": "Työllistettävä on allekirjoittanut hakemuksen", + "text_sv": "translation TBD", + "text_en": "translation TBD", + "ordering": 3, + "created_at": "2021-01-01T00:00:00+02:00", + "modified_at": "2021-01-01T00:00:00+02:00" + } } ] diff --git a/backend/benefit/terms/migrations/0004_alter_terms_terms_type.py b/backend/benefit/terms/migrations/0004_alter_terms_terms_type.py new file mode 100644 index 0000000000..1b1012f4dd --- /dev/null +++ b/backend/benefit/terms/migrations/0004_alter_terms_terms_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2023-05-26 07:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terms', '0003_add_applicantconsent_ordering'), + ] + + operations = [ + migrations.AlterField( + model_name='terms', + name='terms_type', + field=models.CharField(choices=[('terms_of_service', 'Terms of service - shown at login'), ('applicant_terms', 'Terms of application - show at application submit'), ('handler_terms', 'Terms of application for handler - show at application submit for handler')], default='applicant_terms', max_length=64, verbose_name='type of terms'), + ), + ] diff --git a/backend/docker/benefit.Dockerfile b/backend/docker/benefit.Dockerfile index 6589a070b2..9c2d0ef10d 100644 --- a/backend/docker/benefit.Dockerfile +++ b/backend/docker/benefit.Dockerfile @@ -12,7 +12,7 @@ COPY --chown=appuser:appuser shared /shared/ RUN apt-install.sh \ git \ - netcat \ + netcat-traditional \ libpq-dev \ build-essential \ wkhtmltopdf \ diff --git a/backend/docker/kesaseteli.Dockerfile b/backend/docker/kesaseteli.Dockerfile index a47bf8b2c5..d676826712 100644 --- a/backend/docker/kesaseteli.Dockerfile +++ b/backend/docker/kesaseteli.Dockerfile @@ -10,7 +10,7 @@ COPY --chown=appuser:appuser shared /shared/ RUN apt-install.sh \ git \ - netcat \ + netcat-traditional \ libpq-dev \ build-essential \ gettext \ diff --git a/backend/docker/tet.Dockerfile b/backend/docker/tet.Dockerfile index d303dfab52..9e9e243404 100644 --- a/backend/docker/tet.Dockerfile +++ b/backend/docker/tet.Dockerfile @@ -10,7 +10,7 @@ COPY --chown=appuser:appuser shared /shared/ RUN apt-install.sh \ git \ - netcat \ + netcat-traditional \ libpq-dev \ build-essential \ wkhtmltopdf \ diff --git a/backend/shared/shared/azure_adfs/mock_views.py b/backend/shared/shared/azure_adfs/mock_views.py index 4fe136151a..e6477219e6 100644 --- a/backend/shared/shared/azure_adfs/mock_views.py +++ b/backend/shared/shared/azure_adfs/mock_views.py @@ -1,11 +1,11 @@ import logging from urllib.parse import urljoin -import factory from django.conf import settings from django.contrib import auth from django.shortcuts import redirect from django.views.generic import View +from factory.faker import faker from shared.common.tests.factories import UserFactory @@ -21,7 +21,7 @@ class MockOAuth2CallbackView(View): def get(self, request): if not request.user.is_authenticated: user = UserFactory( - username=f"handler_{factory.Faker('user_name')}", + username=f"handler_{faker.Faker().user_name()}", is_staff=True, ) auth.login( diff --git a/backend/shared/shared/service_bus/service_bus_client.py b/backend/shared/shared/service_bus/service_bus_client.py index ccaabb30ff..6487b7e555 100644 --- a/backend/shared/shared/service_bus/service_bus_client.py +++ b/backend/shared/shared/service_bus/service_bus_client.py @@ -1,24 +1,68 @@ +from typing import Optional + import requests from django.conf import settings class ServiceBusClient: + UNKNOWN_INDUSTRY = "n/a" + def __init__(self): - if not all([settings.SERVICE_BUS_INFO_PATH, settings.SERVICE_BUS_TIMEOUT]): + if not all( + [ + settings.SERVICE_BUS_BASE_URL, + settings.SERVICE_BUS_TIMEOUT, + settings.SERVICE_BUS_AUTH_USERNAME, + settings.SERVICE_BUS_AUTH_PASSWORD, + ] + ): raise ValueError("Service bus client settings not configured.") + self.get_company_url = f"{settings.SERVICE_BUS_BASE_URL}/GetCompany" + self.search_company_url = f"{settings.SERVICE_BUS_BASE_URL}/SearchCompany" + self.credentials = ( + settings.SERVICE_BUS_AUTH_USERNAME, + settings.SERVICE_BUS_AUTH_PASSWORD, + ) + self.search_limit = settings.SERVICE_BUS_SEARCH_LIMIT or 10 - def _post(self, url: str, username: str, password: str, data: dict) -> dict: + def get_organisation_info_with_business_id(self, business_id: str) -> dict: + query = {"BusinessId": business_id} + service_bus_data = self._post(url=self.get_company_url, data=query) + try: + return self._get_organisation_data_from_service_bus_data( + service_bus_data["GetCompanyResult"]["Company"] + ) + except (KeyError, TypeError, requests.HTTPError): + return {} + + def search_companies(self, company_name: str) -> list: + query = {"SearchExpression": company_name, "FindAll": False} + service_bus_data = self._post(url=self.search_company_url, data=query) + try: + search_results = service_bus_data["SearchCompanyResult"]["SearchResults"][ + "NameSearchQueryResult" + ] + except (KeyError, TypeError): + search_results = None + + if search_results: + search_results = search_results[: self.search_limit] + return self._format_search_results(search_results) + return [] + + def _post(self, url: str, data: dict) -> dict: response = requests.post( url, - auth=(username, password), + auth=self.credentials, timeout=settings.SERVICE_BUS_TIMEOUT, json=data, ) response.raise_for_status() return response.json() - def get_organisation_data_from_service_bus_data( - self, service_bus_data: dict + @classmethod + def _get_organisation_data_from_service_bus_data( + cls, service_bus_data: dict ) -> dict: """ Get the required company fields from YTJ data. All data will be in Finnish @@ -26,17 +70,15 @@ def get_organisation_data_from_service_bus_data( that hasn't been covered in the code """ - address = self._get_address( - service_bus_data["PostalAddress"]["DomesticAddress"] - ) + address = cls._get_address(service_bus_data["PostalAddress"]["DomesticAddress"]) company_data = { "name": service_bus_data["TradeName"]["Name"], "business_id": service_bus_data["BusinessId"], - "company_form": self._get_company_form(service_bus_data["LegalForm"]), - "company_form_code": self._get_company_form_code( + "company_form": cls._get_company_form(service_bus_data["LegalForm"]), + "company_form_code": cls._get_company_form_code( service_bus_data["LegalForm"] ), - "industry": self._get_industry(service_bus_data.get("BusinessLine")), + "industry": cls._get_industry(service_bus_data.get("BusinessLine", {})), "street_address": address["StreetAddress"], "postcode": address["PostalCode"], "city": address["City"], @@ -44,18 +86,45 @@ def get_organisation_data_from_service_bus_data( return company_data - def get_organisation_info_with_business_id(self, business_id: str) -> dict: - query = {"BusinessId": business_id} - company_info_url = f"{settings.SERVICE_BUS_INFO_PATH}" - service_bus_data = self._post( - company_info_url, - username=settings.SERVICE_BUS_AUTH_USERNAME, - password=settings.SERVICE_BUS_AUTH_PASSWORD, - data=query, + @classmethod + def _get_company_form(cls, legal_form_json: dict) -> str: + # The LegalForm is a code, which is not human-readable. + # We'll try to get the description of the code in Finnish + company_form = cls._get_finnish_description( + legal_form_json["Type"]["Descriptions"]["CodeDescription"] ) - return service_bus_data["GetCompanyResult"]["Company"] + if not company_form: + raise ValueError("Cannot get company form data") + return company_form["Description"] + + @classmethod + def _get_industry(cls, business_line_json: dict) -> str: + # The BusinessLine value is a code, which is not human-readable. + # We'll try to get the description of the code in Finnish + # In suomi.fi test env, some companies do not have this data, so assuming production + # env might have companies with missing data, too + try: + code_description = business_line_json["Type"]["Descriptions"][ + "CodeDescription" + ] + except (KeyError, TypeError): + return cls.UNKNOWN_INDUSTRY + + business_line = cls._get_finnish_description(code_description) + if not business_line: + raise ValueError("Cannot get company form data") + return business_line.get("Description", cls.UNKNOWN_INDUSTRY) - def _get_address(self, address_json): + @staticmethod + def _format_search_results(search_results: list) -> list: + return [ + {"name": company["Name"], "business_id": company["BusinessId"]} + for company in search_results + if company["Name"] and company["BusinessId"] + ] + + @staticmethod + def _get_address(address_json: dict) -> dict: return { "StreetAddress": " ".join( filter( @@ -74,7 +143,8 @@ def _get_address(self, address_json): "City": address_json["City"], } - def _get_company_form_code(self, legal_form_json): + @staticmethod + def _get_company_form_code(legal_form_json: dict) -> int: # return the YRMU code from the response if "Type" not in legal_form_json: raise ValueError("Cannot determine company form") @@ -87,34 +157,6 @@ def _get_company_form_code(self, legal_form_json): "Cannot determine company form - invalid SecondaryCode" ) from e - def _get_company_form(self, legal_form_json): - # The LegalForm is a code, which is not human-readable. - # We'll try to get the description of the code in Finnish - company_form = self._get_finnish_description( - legal_form_json["Type"]["Descriptions"]["CodeDescription"] - ) - if not company_form: - raise ValueError("Cannot get company form data") - return company_form["Description"] - - UNKNOWN_INDUSTRY = "n/a" - - def _get_industry(self, business_line_json): - # The BusinessLine value is a code, which is not human-readable. - # We'll try to get the description of the code in Finnish - # In suomi.fi test env, some companies do not have this data, so assuming production - # env might have companies with missing data, too - try: - code_description = business_line_json["Type"]["Descriptions"][ - "CodeDescription" - ] - except (KeyError, TypeError): - return self.UNKNOWN_INDUSTRY - - business_line = self._get_finnish_description(code_description) - if not business_line: - raise ValueError("Cannot get company form data") - return business_line.get("Description", self.UNKNOWN_INDUSTRY) - - def _get_finnish_description(self, descriptions): + @staticmethod + def _get_finnish_description(descriptions: dict) -> Optional[dict]: return next((desc for desc in descriptions if desc["Language"] == "fi"), None) diff --git a/backend/shared/shared/yrtti/enums.py b/backend/shared/shared/yrtti/enums.py new file mode 100644 index 0000000000..0c66f4e337 --- /dev/null +++ b/backend/shared/shared/yrtti/enums.py @@ -0,0 +1,19 @@ +from enum import Enum + + +class AssociationNameTypeMap(Enum): + NAME = "P" + AUXILARY_NAME = "A" + + +class AssociationStatusMap(Enum): + # Works also for AssociationNameStatus + PENDING = "V" + REGISTERED = "R" + DISCONTINIUED = "L" + + +class AssociationNameLanguageMap(Enum): + FINNISH = "FI" + SWEDISH = "SE" + ENGLISH = "EN" diff --git a/backend/shared/shared/yrtti/yrtti_client.py b/backend/shared/shared/yrtti/yrtti_client.py index 335bae2a6b..1219e6013c 100644 --- a/backend/shared/shared/yrtti/yrtti_client.py +++ b/backend/shared/shared/yrtti/yrtti_client.py @@ -3,24 +3,80 @@ from shared.service_bus.enums import YtjOrganizationCode -TARGET_ASSOCIATION_NAME_TYPE = "P" -TARGET_ASSOCIATION_NAME_LANGUAGE = "FI" -TARGET_ASSOCIATION_NAME_STATUS = "R" +from .enums import ( + AssociationNameLanguageMap, + AssociationNameTypeMap, + AssociationStatusMap, +) class YRTTIClient: + target_association_status = AssociationStatusMap.REGISTERED.value + target_association_name_status = AssociationStatusMap.REGISTERED.value + target_association_name_type = AssociationNameTypeMap.NAME.value + target_association_name_language = AssociationNameLanguageMap.FINNISH.value + def __init__(self): - if not all([settings.YRTTI_BASIC_INFO_PATH, settings.YRTTI_TIMEOUT]): + if not all( + [ + settings.YRTTI_BASE_URL, + settings.YRTTI_TIMEOUT, + settings.YRTTI_AUTH_USERNAME, + settings.YRTTI_AUTH_PASSWORD, + ] + ): raise ValueError("YRTTI client settings not configured.") + self.get_association_url = f"{settings.YRTTI_BASE_URL}/BasicInfo" + self.search_association_url = f"{settings.YRTTI_BASE_URL}/AdvancedSearch" + self.credentials = (settings.YRTTI_AUTH_USERNAME, settings.YRTTI_AUTH_PASSWORD) + self.search_limit = settings.YRTTI_SEARCH_LIMIT or 10 + + def get_association_info_with_business_id(self, business_id: str) -> dict: + query = {"BusinessId": business_id} + yrtti_data = self._post( + self.get_association_url, + data=query, + ) + try: + return self._get_association_data_from_yrtti_data( + yrtti_data["BasicInfoResponse"] + ) + except (KeyError, TypeError, requests.HTTPError): + return {} - def _post(self, url: str, username: str, password: str, data: dict) -> dict: + def search_associations(self, name: str) -> list: + query = { + "Name": name, + "AssociationStatus": [self.target_association_status], + "NameStatus": [self.target_association_name_status], + "NameType": [self.target_association_name_type], + } + yrtti_data = self._post( + self.search_association_url, + data=query, + ) + try: + search_results = yrtti_data["AdvancedSearchResponse"]["MatchedAssociation"] + except (KeyError, TypeError): + search_results = None + + if search_results: + search_results = search_results[: self.search_limit] + return self._format_search_results(search_results) + return [] + + def _post(self, url: str, data: dict) -> dict: response = requests.post( - url, auth=(username, password), timeout=settings.YRTTI_TIMEOUT, json=data + url, + auth=self.credentials, + timeout=settings.YRTTI_TIMEOUT, + json=data, ) response.raise_for_status() return response.json() - def get_association_data_from_yrtti_data(self, yrtti_data: dict) -> dict: + @classmethod + def _get_association_data_from_yrtti_data(cls, yrtti_data: dict) -> dict: """ Get the required company fields from YRTTI data. @@ -28,52 +84,61 @@ def get_association_data_from_yrtti_data(self, yrtti_data: dict) -> dict: a field for company form. Use the YTJ "yritysmuoto" code for associations when creating the Company objects """ - association_name = self._get_active_association_name( + association_name_info = cls._get_active_association_name( yrtti_data["AssociationNameInfo"] ) # There might be a list of addresses, but it is impossible to identify which one is the primary address from # the data so we just select the first one. Most of the time there is only 1 address address = yrtti_data["Address"][0] company_data = { - "name": association_name["AssociationName"], + "name": association_name_info["AssociationName"], "business_id": yrtti_data["BusinessId"], "company_form": YtjOrganizationCode.ASSOCIATION_FORM_CODE_DEFAULT.label, - "company_form_code": YtjOrganizationCode.ASSOCIATION_FORM_CODE_DEFAULT, - "industry": association_name["AssociationIndustry"] or "", - "street_address": self._sanitize_text(address["StreetName"]), + "company_form_code": YtjOrganizationCode.ASSOCIATION_FORM_CODE_DEFAULT.value, + "industry": association_name_info["AssociationIndustry"] or "", + "street_address": cls._sanitize_text(address["StreetName"]), "postcode": address["PostCode"], "city": address["City"], } return company_data - def get_association_info_with_business_id(self, business_id: str) -> dict: - query = {"BusinessId": business_id} - company_info_url = f"{settings.YRTTI_BASIC_INFO_PATH}" - yrtti_data = self._post( - company_info_url, - username=settings.YRTTI_AUTH_USERNAME, - password=settings.YRTTI_AUTH_PASSWORD, - data=query, - ) - return yrtti_data["BasicInfoResponse"] + @classmethod + def _format_search_results(cls, search_results: list) -> list: + formatted = [] + for result in search_results: + if not result["BusinessId"] or not result["AssociationNameInfo"]: + continue - def _sanitize_text(self, text: str) -> str: - # Some text fields from YRTTI includes \n character, replace it with space - if text: - return text.replace("\n", " ") - return "" + association_name_info = cls._get_active_association_name( + result["AssociationNameInfo"] + ) + if not association_name_info: + continue + + formatted.append( + { + "name": association_name_info["AssociationName"], + "business_id": result["BusinessId"], + } + ) + return formatted - def _get_active_association_name(self, name_info: dict) -> str: - # There will be the case where an association has more than one name - # Here we only pick up latest name available - active_names = [ + @classmethod + def _get_active_association_name(cls, name_info: list) -> dict: + # If active Finnish name found, return it, otherwise return the first active name + target_language_names = [ name for name in name_info - if name["AssociationNameType"] == TARGET_ASSOCIATION_NAME_TYPE - and name["AssociationNameLanguage"] == TARGET_ASSOCIATION_NAME_LANGUAGE - and name["AssociationNameStatus"] == TARGET_ASSOCIATION_NAME_STATUS + if name["AssociationNameLanguage"] == cls.target_association_name_language ] - if len(active_names) == 0: - raise ValueError("Cannot find active association name") - return active_names[0] + if target_language_names: + return target_language_names[0] + return name_info[0] + + @staticmethod + def _sanitize_text(text: str) -> str: + # Some text fields from YRTTI includes \n character, replace it with space + if text: + return text.replace("\n", " ") + return "" diff --git a/compose.benefit.yml b/compose.benefit.yml index f71e5c60d9..e722903754 100644 --- a/compose.benefit.yml +++ b/compose.benefit.yml @@ -10,8 +10,8 @@ services: POSTGRES_USER: benefit POSTGRES_PASSWORD: benefit POSTGRES_DB: benefit - LC_COLLATE: 'fi_FI.UTF-8' - LC_CTYPE: 'fi_FI.UTF-8' + LC_COLLATE: "fi_FI.UTF-8" + LC_CTYPE: "fi_FI.UTF-8" ports: - 5434:5432 volumes: @@ -33,7 +33,6 @@ services: - ./backend/shared:/shared depends_on: - postgres - - minio container_name: benefit-backend applicant: @@ -85,7 +84,7 @@ services: - 127.0.0.1:3100:3100 mailhog: - image: 'mailhog/mailhog@sha256:8d76a3d4ffa32a3661311944007a415332c4bb855657f4f6c57996405c009bea' + image: "mailhog/mailhog@sha256:8d76a3d4ffa32a3661311944007a415332c4bb855657f4f6c57996405c009bea" ports: - 1025:1025 - 8025:8025 @@ -93,41 +92,9 @@ services: networks: - default - minio: - image: minio/minio@sha256:b6ee4f78beddd690e4b4b0fe95bd88ea93925ef15e4e7f4a9de7312a9fe2e1f6 - ports: - - 9000:9000 - - 9090:9090 - container_name: benefit-minio - volumes: - - s3-volume:/data - environment: - - MINIO_ROOT_USER=minio-root - - MINIO_ROOT_PASSWORD=minio-pass - command: server --console-address :9090 --address :9000 /data - healthcheck: - test: - [ - "CMD", - "curl", - "-f", - "http://localhost:9000/minio/health/live" - ] - retries: 3 - timeout: 5s - # Create an initial bucket for local development - createbucket: - image: minio/mc@sha256:0099b4225101e65c636838bf56be2a717fa71c80a718bee0c6eb6ecf767c41a0 - container_name: benefit-minio-client - depends_on: - - minio - entrypoint: > - /bin/sh -c ' mc config host add s3 http://minio:9000 minio-root minio-pass --api S3v4; [[ ! -z "`mc ls s3 | grep local-s3-bucket`" ]] || mc mb s3/local-s3-bucket; exit 0; ' volumes: pgdata: driver: local - s3-volume: - driver: local networks: default: diff --git a/frontend/benefit/applicant/package.json b/frontend/benefit/applicant/package.json index fa9c3e239d..417fbf2f2e 100644 --- a/frontend/benefit/applicant/package.json +++ b/frontend/benefit/applicant/package.json @@ -17,7 +17,7 @@ "dependencies": { "@frontend/shared": "*", "@frontend/benefit-shared": "*", - "@react-pdf/renderer": "^2.1.1", + "@react-pdf/renderer": "^3.1.12", "@sentry/browser": "^7.16.0", "@sentry/nextjs": "^7.16.0", "axios": "^0.27.2", @@ -34,12 +34,12 @@ "next-i18next": "^10.5.0", "next-plugin-custom-babel-config": "^1.0.5", "next-transpile-modules": "^9.0.0", - "pdfjs-dist": "^2.10.377", + "pdfjs-dist": "3.6.172", "react": "^18.0.0", "react-dom": "^18.0.0", "react-input-mask": "^2.0.4", "react-loading-skeleton": "^3.0.3", - "react-pdf": "^5.7.2", + "react-pdf": "^7.1.2", "react-query": "^3.34.0", "react-toastify": "^9.0.4", "snakecase-keys": "^5.4.2", diff --git a/frontend/benefit/applicant/public/locales/en/common.json b/frontend/benefit/applicant/public/locales/en/common.json index 070d5ea547..6b3ce28ee3 100644 --- a/frontend/benefit/applicant/public/locales/en/common.json +++ b/frontend/benefit/applicant/public/locales/en/common.json @@ -470,12 +470,20 @@ "login": { "login": "Sign in to the service", "logoutMessageLabel": "You have logged out", - "errorLabel": "An unexpected error occurred. Please, sign in again. ", + "errorLabel": "An unexpected error occurred. Please, sign in again.", "sessionExpiredLabel": "User session expired. Sign in again", - "infoLabel": "Using the service requires strong authentication", + "infoLabel": "Problems logging in?", "logoutInfoContent": "You have been logged out. If you want to continue to use the service, you have to log in again click the button here below.", - "infoContent": "You should identify yourself to be able to advance to the service. You can choose yourself which identification method you want to use. Click on the bottom below to identify yourself.", - "termsOfServiceHeader": "Information about the processing of the employer representatives’ personal data" + "infoContent": "Send us an email to helsinkilisa@hel.fi.", + "termsOfServiceHeader": "Information about the processing of the employer representatives’ personal data", + "heading": "Log in to the Helsinki benefit service", + "infoText1": "Apply for Helsinki benefit, a discretionary support for a private or third-sector employer that hires an unemployed Helsinki resident. You need a Suomi.fi e-Authorization to do business on behalf of your organization.", + "subheading1": "Log in with you bank access codes or mobile certificate", + "infoText2": "When you log in for the first time, your own Helsinki profile is automatically created for you.", + "subheading2": "Acquire Suomi.fi e-Authorization", + "infoText3": "If you do not yet have the right to do business on behalf of your organization, you can get yourself a Suomi.fi e-Authorization.", + "authorization": "See the instructions for obtaining authorization", + "suomifiUrl": "https://www.suomi.fi/e-authorizations" }, "errorPage": { "title": "An error has unfortunately occurred", @@ -511,8 +519,8 @@ "errorMessage": "Something went wrong. Please try again later." }, "pdfViewer": { - "previous": "Previous", - "next": "Next", + "previous": "Previous page", + "next": "Next page", "page": "Page", "terms": "Terms.PDF" }, @@ -548,14 +556,6 @@ "noMessages": "No messages", "close": "Close" }, - "supportingContent": { - "contact": { - "title": "", - "text": "Do you need help? If anything is unclear, you can email us", - "emailAddress": "helsinkilisa@hel.fi", - "buttonText": "" - } - }, "application": { "tooltipShowInfo": "Show information" }, @@ -564,6 +564,98 @@ "home": "Helsinki benefit - Frontpage", "createApplication": "Helsinki benefit - Application - Create", "editApplication": "Helsinki benefit - Application - Edit", - "viewApplication": "Helsinki benefit - Application - View" + "viewApplication": "Helsinki benefit - Application - View", + "accessibilityStatement": "Helsinki benefit - Accessibility statement" + }, + "accessibilityStatement": { + "h1": "Saavutettavuusseloste", + "sections": { + "section1": { + "heading1": "Digitaalinen työllisyyden Helsinki-lisä -asiointipalvelu", + "content1": "Tämä saavutettavuusseloste koskee digitaalista työllisyyden Helsinki-lisä -asiointipalvelua. Palvelun osoite on https://helsinki-lisa.hel.fi" + }, + "section2": { + "heading1": "Helsingin kaupungin tavoite", + "content1": "Digitaalisten palveluiden saavutettavuudessa Helsingin tavoitteena on pyrkiä vähintään WCAG ohjeiston mukaiseen AA- tai sitä parempaan tasoon, mikäli se on kohtuudella mahdollista.", + "content2": "Helsingin tavoitteena on pyrkiä digitaalisten palveluiden saavutettavuudessa vähintään WCAG-ohjeistuksen mukaiseen AA- tai sitä parempaan tasoon." + }, + "section3": { + "heading1": "Helsinki-lisä -asiointipalvelun saavutettavuuden tila", + "content1": "Helsinki-lisä -asiointipalvelu täyttää lain asettamat kriittiset saavutettavuusvaatimukset WCAG v2.1 -tason AA mukaisesti seuraavin havaituin puuttein.", + "heading2": "Ei-saavutettava sisältö, havaitut puutteet ja puutteiden korjaus", + "content2": "Jäljempänä on esitetty havaittuja vielä korjaamattomia puutteita.", + "list": { + "item1": { + "heading": "Ruudunlukuohjelman virheellinen kohdistusjärjestys", + "text": "Kun käyttäjä avaa uuden hakemuksen, ei ruudunlukuohjelman kohdistusta viedä lomakkeen alkuun, vaan kohdistus sijaitsee virheellisesti keskellä Työnantajan tiedot-osiota." + }, + "item2": { + "heading": "Nykyistä työvaihetta ei välitetä avustavalle teknologialle", + "text": "Lomakkeen työvaihelistauksesta ei välity teknistä informaatiota avustavalle teknologialle siitä, missä työvaiheessa käyttäjä parhaillaan on." + }, + "item3": { + "heading": "Useamman virheellisen lomakentän ilmoittaminen", + "text": "Palvelun virheilmoitukset esitetään useimmiten siten, että lomakkeen seuraavaan osioon siirtymisyrityksen yhteydessä käyttäjän selainkohdistus viedään lomakkeen ensimmäiseen virheelliseen lomakekenttään. Jos useammassa kentässä on virhe, muiden virheellisten kohtien löytäminen on hankalaa." + }, + "item4": { + "heading": "Virheilmoitusten tekstit liian yleisluontoisia", + "text": "Monissa kentissä käytettiin samaa virheilmoitusta eli ”Virheellinen arvo”. Tämä virheilmoitus on esim. Etunimi, Sukunimi, Lomaraha ja Työaika tuntia viikossa -kentissä." + }, + "item5": { + "heading": "Päivämäärien asettaminen ei onnistu tekstinsyöttökenttien avulla", + "text": "Ongelman vakavuutta pienentää se, että kalenteritoiminto on saavutettava ruudunlukuohjelmaa ja näppäimistöä käytettäessä." + } + }, + "heading3": "Puutteiden korjaus", + "content3": "Havaittuja saavutettavuuspuutteita pyritään korjaamaan jatkuvasti. Tässä selosteessa havaittujen saavutettavuuspuutteiden listausta päivitetään sen mukaan, kun puutteita saadaan korjattua." + }, + "section4": { + "heading1": "Saavutettavuusselosteen laatiminen", + "content1": "Tämä seloste on päivitetty 05.06.2023." + }, + "section5": { + "heading1": "Saavutettavuuden arviointi", + "content1": "Saavutettavuuden arvioinnissa on noudatettu Helsingin kaupungin työohjetta ja menetelmiä, jotka pyrkivät varmistamaan sivuston saavutettavuuden kaikissa työvaiheissa.", + "content2": "Saavutettavuus on tarkistettu ulkopuolisen asiantuntijan suorittamana auditointina. Ulkopuolisen asiantuntija-auditoinnin on suorittanut Siteimprov 23.3.2022.", + "content3": "Saavutettavuus on tarkistettu käyttäen ohjelmallista saavutettavuustarkistusta sekä sivuston ja sisällön manuaalista tarkistusta. Ohjelmallinen tarkistus on suoritettu käyttäen Siteimproven saavutettavuuden automaattista testaustyökalua ja selainlaajennusta.", + "content4": "Manuaalisessa testauksessa on käytetty Chrome- ja Firefox-selaimia, niiden 200% tiloja sekä tietoteknisiä apuvälineitä, kuten ruudunlukuohjelmia, ohjaimia ja erikoisnäppäimistöjä. Mobiilitestaus toteutettiin iOS- ja Android-käyttöjärjestelmillä ja niille tarkoitetuilla ruudunlukuohjelmilla." + }, + "section6": { + "heading1": "Saavutettavuusselosteen päivittäminen", + "content1": "Sivuston saavutettavuudesta huolehditaan jatkuvalla valvonnalla tekniikan tai sisällön muuttuessa, sekä määräajoin suoritettavalla tarkistuksella. Tätä selostetta päivitetään sivuston muutosten ja saavutettavuuden tarkistusten yhteydessä." + }, + "section7": { + "heading1": "Huomasitko puutteita saavutettavuudessa?", + "content1": "Pyrimme jatkuvasti parantamaan verkkopalvelun saavutettavuutta. Jos löydät ongelmia, joita ei ole kuvattu tällä sivulla, ilmoita niistä meille ja teemme parhaamme puutteiden korjaamiseksi.", + "content2": "Anna palautetta tällä verkkolomakkeella ", + "content3": "Palautekanavan kautta voit myös pyytää saavutettavaan muotoon muokattuja tietoja Hel.fi-sivuston sisällöstä." + }, + "section8": { + "heading1": "Tietojen pyytäminen saavutettavassa muodossa", + "content1": "Mikäli et koe saavasi sivuston sisältöä saavutettavassa muodossa, voit pyytää tietoja palautelomakkeella ", + "content1end": ". Tiedusteluun pyritään vastaamaan kohtuullisessa ajassa." + }, + "section9": { + "heading1": "Saavutettavuuden valvonta", + "content1": "Etelä-Suomen aluehallintovirasto valvoo saavutettavuusvaatimusten toteutumista. Jos et ole tyytyväinen saamaasi vastaukseen tai et saa vastausta lainkaan kahden viikon aikana, voit antaa palautteen Etelä-Suomen aluehallintovirastoon.", + "contact1": "Etelä-Suomen aluehallintovirasto,", + "contact2": "Saavutettavuuden valvonnan yksikkö", + "contact3": "www.saavutettavuusvaatimukset.fi", + "contact4": "saavutettavuus@avi.fi", + "content2": "Helsinki-lisä -asiointi palvelun saavutettavuusselosteesta vastaa Helsingin kaupungin työllisyyspalvelut -yksikkö." + }, + "section10": { + "heading1": "Helsingin kaupunki ja saavutettavuus", + "content1": "Kaupunki edistää digitaalisten palveluiden saavutettavuutta yhdenmukaistamalla julkaisutyötä ja järjestämällä saavutettavuuteen keskittyvää koulutusta henkilökunnalleen.", + "content2": "Sivustojen saavutettavuuden tasoa seurataan jatkuvasti sivustoja ylläpidettäessä. Havaittuihin puutteisiin reagoidaan välittömästi. Tarvittavat muutokset pyritään suorittamaan mahdollisimman nopeasti." + }, + "section11": { + "heading1": "Vammaiset ja avustavien teknologioiden käyttäjät", + "content1": "Kaupunki tarjoaa neuvontaa ja tukea vammaisille ja avustavien teknologioiden käyttäjille. Tukea on saatavilla kaupungin sivuilla ilmoitetuilta neuvontasivuilta sekä puhelinneuvonnasta." + }, + "section12": { + "heading1": "Palaute ja yhteystiedot" + } + } } } diff --git a/frontend/benefit/applicant/public/locales/fi/common.json b/frontend/benefit/applicant/public/locales/fi/common.json index 74ac09b961..7020b7519b 100644 --- a/frontend/benefit/applicant/public/locales/fi/common.json +++ b/frontend/benefit/applicant/public/locales/fi/common.json @@ -432,7 +432,7 @@ "min": "Arvon tulee olla vähintään {{min}}" }, "phoneNumber": { - "max": "Arvon enimmäispituus on {{max}}" + "max": "Kentän maksimipituus on {{max}}" }, "string": { "max": "Tämä kenttä voi olla korkeintaan {{max}} merkkiä pitkä", @@ -468,14 +468,22 @@ }, "select": "Valitse", "login": { - "login": "Kirjaudu palveluun", + "login": "Kirjaudu sisään", "logoutMessageLabel": "Olet kirjautunut ulos", "errorLabel": "Tapahtui tuntematon virhe. Kirjaudu uudelleen sisään", "sessionExpiredLabel": "Käyttäjäsessio vanhentui. Kirjaudu uudelleen sisään", - "infoLabel": "Palvelun käyttäminen edellyttää vahvaa tunnistautumista", + "infoLabel": "Ongelmia kirjautumisessa?", "logoutInfoContent": "Sinut on kirjattu ulos. Jos haluat jatkaa asiointia palvelussa, kirjaudu uudelleen sisään klikkaamalla alla olevasta painikkeesta.", - "infoContent": "Jotta voit edetä palvelussa, sinun täytyy tunnistautua. Voit valita itse, mitä tunnistustapaa haluat käyttää. Tunnistustietosi kulkevat suojatussa yhteydessä. Klikkaa alla olevasta painikkeesta tunnistautuaksesi.", - "termsOfServiceHeader": "Tietoa työnantajan edustajien henkilötietojen käsittelystä" + "infoContent": "Lähetä meille sähköposti osoitteeseen helsinkilisa@hel.fi.", + "termsOfServiceHeader": "Tietoa työnantajan edustajien henkilötietojen käsittelystä", + "heading": "Kirjaudu Helsinki-lisän asiointipalveluun", + "infoText1": "Hae organisaatiollesi taloudellista tukea, Helsinki-lisää, työttömän helsinkiläisen työllistämiseen. Tarvitset Suomi.fi-valtuutuksen asioimiseen organisaatiosi puolesta.", + "subheading1": "Kirjaudu pankkitunnuksilla tai mobiilivarmenteella", + "infoText2": "Kun kirjaudut ensimmäisen kerran, sinulle luodaan automaattisesti oma Helsinki-profiili tunnus.", + "subheading2": "Hanki Suomi.fi -valtuutus", + "infoText3": "Jos sinulla ei ole vielä oikeutta asioida organisaatiosi puolesta, voit hankkia itsellesi Suomi.fi -valtuuden.", + "authorization": "Katso ohjeet valtuutuksen hankkimiseen", + "suomifiUrl": "https://www.suomi.fi/valtuudet" }, "errorPage": { "title": "Palvelussa on valitettavasti tapahtunut virhe", @@ -511,8 +519,8 @@ "errorMessage": "Jotakin meni vikaan. Ole hyvä ja kokeile uudelleen myöhemmin." }, "pdfViewer": { - "previous": "Edellinen", - "next": "Seuraava", + "previous": "Edellinen sivu", + "next": "Seuraava sivu", "page": "Sivu", "terms": "Ehdot.PDF" }, @@ -548,14 +556,6 @@ "noMessages": "Ei viestejä", "close": "Sulje" }, - "supportingContent": { - "contact": { - "title": "", - "text": "Tarvitsetko apua? Jos jokin asia jäi epäselväksi, voit lähettää meille sähköpostia", - "emailAddress": "helsinkilisa@hel.fi", - "buttonText": "" - } - }, "application": { "tooltipShowInfo": "Näytä info" }, @@ -564,6 +564,98 @@ "home": "Helsinki-lisä - Etusivu", "createApplication": "Helsinki-lisä - Hakemus - Luo uusi", "editApplication": "Helsinki-lisä - Hakemus - Muokkaa hakemusta", - "viewApplication": "Helsinki-lisä - Hakemus - Tarkastele hakemusta" + "viewApplication": "Helsinki-lisä - Hakemus - Tarkastele hakemusta", + "accessibilityStatement": "Helsinki-lisä - Saavutettavuusseloste" + }, + "accessibilityStatement": { + "h1": "Saavutettavuusseloste", + "sections": { + "section1": { + "heading1": "Digitaalinen työllisyyden Helsinki-lisä -asiointipalvelu", + "content1": "Tämä saavutettavuusseloste koskee digitaalista työllisyyden Helsinki-lisä -asiointipalvelua. Palvelun osoite on https://helsinki-lisa.hel.fi" + }, + "section2": { + "heading1": "Helsingin kaupungin tavoite", + "content1": "Digitaalisten palveluiden saavutettavuudessa Helsingin tavoitteena on pyrkiä vähintään WCAG ohjeiston mukaiseen AA- tai sitä parempaan tasoon, mikäli se on kohtuudella mahdollista.", + "content2": "Helsingin tavoitteena on pyrkiä digitaalisten palveluiden saavutettavuudessa vähintään WCAG-ohjeistuksen mukaiseen AA- tai sitä parempaan tasoon." + }, + "section3": { + "heading1": "Helsinki-lisä -asiointipalvelun saavutettavuuden tila", + "content1": "Helsinki-lisä -asiointipalvelu täyttää lain asettamat kriittiset saavutettavuusvaatimukset WCAG v2.1 -tason AA mukaisesti seuraavin havaituin puuttein.", + "heading2": "Ei-saavutettava sisältö, havaitut puutteet ja puutteiden korjaus", + "content2": "Jäljempänä on esitetty havaittuja vielä korjaamattomia puutteita.", + "list": { + "item1": { + "heading": "Ruudunlukuohjelman virheellinen kohdistusjärjestys", + "text": "Kun käyttäjä avaa uuden hakemuksen, ei ruudunlukuohjelman kohdistusta viedä lomakkeen alkuun, vaan kohdistus sijaitsee virheellisesti keskellä Työnantajan tiedot-osiota." + }, + "item2": { + "heading": "Nykyistä työvaihetta ei välitetä avustavalle teknologialle", + "text": "Lomakkeen työvaihelistauksesta ei välity teknistä informaatiota avustavalle teknologialle siitä, missä työvaiheessa käyttäjä parhaillaan on." + }, + "item3": { + "heading": "Useamman virheellisen lomakentän ilmoittaminen", + "text": "Palvelun virheilmoitukset esitetään useimmiten siten, että lomakkeen seuraavaan osioon siirtymisyrityksen yhteydessä käyttäjän selainkohdistus viedään lomakkeen ensimmäiseen virheelliseen lomakekenttään. Jos useammassa kentässä on virhe, muiden virheellisten kohtien löytäminen on hankalaa." + }, + "item4": { + "heading": "Virheilmoitusten tekstit liian yleisluontoisia", + "text": "Monissa kentissä käytettiin samaa virheilmoitusta eli ”Virheellinen arvo”. Tämä virheilmoitus on esim. Etunimi, Sukunimi, Lomaraha ja Työaika tuntia viikossa -kentissä." + }, + "item5": { + "heading": "Päivämäärien asettaminen ei onnistu tekstinsyöttökenttien avulla", + "text": "Ongelman vakavuutta pienentää se, että kalenteritoiminto on saavutettava ruudunlukuohjelmaa ja näppäimistöä käytettäessä." + } + }, + "heading3": "Puutteiden korjaus", + "content3": "Havaittuja saavutettavuuspuutteita pyritään korjaamaan jatkuvasti. Tässä selosteessa havaittujen saavutettavuuspuutteiden listausta päivitetään sen mukaan, kun puutteita saadaan korjattua." + }, + "section4": { + "heading1": "Saavutettavuusselosteen laatiminen", + "content1": "Tämä seloste on päivitetty 05.06.2023." + }, + "section5": { + "heading1": "Saavutettavuuden arviointi", + "content1": "Saavutettavuuden arvioinnissa on noudatettu Helsingin kaupungin työohjetta ja menetelmiä, jotka pyrkivät varmistamaan sivuston saavutettavuuden kaikissa työvaiheissa.", + "content2": "Saavutettavuus on tarkistettu ulkopuolisen asiantuntijan suorittamana auditointina. Ulkopuolisen asiantuntija-auditoinnin on suorittanut Siteimprov 23.3.2022.", + "content3": "Saavutettavuus on tarkistettu käyttäen ohjelmallista saavutettavuustarkistusta sekä sivuston ja sisällön manuaalista tarkistusta. Ohjelmallinen tarkistus on suoritettu käyttäen Siteimproven saavutettavuuden automaattista testaustyökalua ja selainlaajennusta.", + "content4": "Manuaalisessa testauksessa on käytetty Chrome- ja Firefox-selaimia, niiden 200% tiloja sekä tietoteknisiä apuvälineitä, kuten ruudunlukuohjelmia, ohjaimia ja erikoisnäppäimistöjä. Mobiilitestaus toteutettiin iOS- ja Android-käyttöjärjestelmillä ja niille tarkoitetuilla ruudunlukuohjelmilla." + }, + "section6": { + "heading1": "Saavutettavuusselosteen päivittäminen", + "content1": "Sivuston saavutettavuudesta huolehditaan jatkuvalla valvonnalla tekniikan tai sisällön muuttuessa, sekä määräajoin suoritettavalla tarkistuksella. Tätä selostetta päivitetään sivuston muutosten ja saavutettavuuden tarkistusten yhteydessä." + }, + "section7": { + "heading1": "Huomasitko puutteita saavutettavuudessa?", + "content1": "Pyrimme jatkuvasti parantamaan verkkopalvelun saavutettavuutta. Jos löydät ongelmia, joita ei ole kuvattu tällä sivulla, ilmoita niistä meille ja teemme parhaamme puutteiden korjaamiseksi.", + "content2": "Anna palautetta tällä verkkolomakkeella ", + "content3": "Palautekanavan kautta voit myös pyytää saavutettavaan muotoon muokattuja tietoja Hel.fi-sivuston sisällöstä." + }, + "section8": { + "heading1": "Tietojen pyytäminen saavutettavassa muodossa", + "content1": "Mikäli et koe saavasi sivuston sisältöä saavutettavassa muodossa, voit pyytää tietoja palautelomakkeella ", + "content1end": ". Tiedusteluun pyritään vastaamaan kohtuullisessa ajassa." + }, + "section9": { + "heading1": "Saavutettavuuden valvonta", + "content1": "Etelä-Suomen aluehallintovirasto valvoo saavutettavuusvaatimusten toteutumista. Jos et ole tyytyväinen saamaasi vastaukseen tai et saa vastausta lainkaan kahden viikon aikana, voit antaa palautteen Etelä-Suomen aluehallintovirastoon.", + "contact1": "Etelä-Suomen aluehallintovirasto,", + "contact2": "Saavutettavuuden valvonnan yksikkö", + "contact3": "www.saavutettavuusvaatimukset.fi", + "contact4": "saavutettavuus@avi.fi", + "content2": "Helsinki-lisä -asiointi palvelun saavutettavuusselosteesta vastaa Helsingin kaupungin työllisyyspalvelut -yksikkö." + }, + "section10": { + "heading1": "Helsingin kaupunki ja saavutettavuus", + "content1": "Kaupunki edistää digitaalisten palveluiden saavutettavuutta yhdenmukaistamalla julkaisutyötä ja järjestämällä saavutettavuuteen keskittyvää koulutusta henkilökunnalleen.", + "content2": "Sivustojen saavutettavuuden tasoa seurataan jatkuvasti sivustoja ylläpidettäessä. Havaittuihin puutteisiin reagoidaan välittömästi. Tarvittavat muutokset pyritään suorittamaan mahdollisimman nopeasti." + }, + "section11": { + "heading1": "Vammaiset ja avustavien teknologioiden käyttäjät", + "content1": "Kaupunki tarjoaa neuvontaa ja tukea vammaisille ja avustavien teknologioiden käyttäjille. Tukea on saatavilla kaupungin sivuilla ilmoitetuilta neuvontasivuilta sekä puhelinneuvonnasta." + }, + "section12": { + "heading1": "Palaute ja yhteystiedot" + } + } } } diff --git a/frontend/benefit/applicant/public/locales/sv/common.json b/frontend/benefit/applicant/public/locales/sv/common.json index 6ba1adccc9..ac3f6fcb2c 100644 --- a/frontend/benefit/applicant/public/locales/sv/common.json +++ b/frontend/benefit/applicant/public/locales/sv/common.json @@ -391,7 +391,7 @@ }, "applicationSaved": { "label": "Applikationen sparas", - "message": "Helsinforstillägg applikationen {{applicationNumber}} {{applicantName}} sparas." + "message": "Helsingforstillägg applikationen {{applicationNumber}} {{applicantName}} sparas." }, "applicationDeleted": { "label": "Utkast raderat", @@ -472,10 +472,18 @@ "logoutMessageLabel": "Du har loggat ut", "errorLabel": "Ett okänt fel inträffade. Logga in på nytt.", "sessionExpiredLabel": "Användarsessionen föråldrades. Logga in på nytt.", - "infoLabel": "Att använda tjänsten kräver stark autentisering", + "infoLabel": "Problem med att logga in?", "logoutInfoContent": "Du har nu loggats ut. Om du vill fortsätta använda tjänsten, logga in på nytt genom att klicka på knappen nedan.", - "infoContent": "Du bör identifiera dig för att kunna avancera till tjänsten. Du kan själv välja det identifieringssätt du vill använda. Identifieringsuppgifterna rör sig i en säker anslutning. Klicka på nedanstående knapp för att identifiera dig.", - "termsOfServiceHeader": "Information om behandlingen av arbetsgivarens representanters uppgifter" + "infoContent": "Skicka ett mejl till helsinkilisa@hel.fi.", + "termsOfServiceHeader": "Information om behandlingen av arbetsgivarens representanters uppgifter", + "heading": "Logga in i Helsingforstillägg tjänsten", + "infoText1": "Sök ekonomiskt stöd, Helsingforstillägg, för att sysselsätta arbetslösa helsingforsbor. Du behöver Suomi.fi-fullmakter för att utföra ärenden för din organisation.", + "subheading1": "Logga in med bankkoder eller mobilt certifikat", + "infoText2": "När du loggar in för första gången skapas automatiskt en egen Helsingforsprofil för dig.", + "subheading2": "Skaffa Suomi.fi-fullmakter", + "infoText3": "Om du ännu inte har rättighet att handla på din organisations vägnar kan du skaffa Suomi.fi-fullmakter för dig själv.", + "authorization": "Se instruktioner för att skaffa autorisering", + "suomifiUrl": "https://www.suomi.fi/fullmakter" }, "errorPage": { "title": "Tyvärr har det inträffat ett fel i tjänsten", @@ -511,9 +519,9 @@ "errorMessage": "Något gick fel. Vänligen försök igen senare." }, "pdfViewer": { - "previous": "Previous", - "next": "Next", - "page": "Page", + "previous": "Föregående sida", + "next": "Nästa sida", + "page": "Sida", "terms": "Terms.PDF" }, "utility": { @@ -548,22 +556,106 @@ "noMessages": "Inga meddelanden", "close": "Stäng" }, - "supportingContent": { - "contact": { - "title": "", - "text": "Behöver du hjälp? Om något är oklart kan du mejla oss", - "emailAddress": "helsinkilisa@hel.fi", - "buttonText": "" - } - }, "application": { "tooltipShowInfo": "Visa information" }, "pageTitles": { - "login": "Helsinforstillägg - Logga in", - "home": "Helsinforstillägg - Framsida", - "createApplication": "Helsinforstillägg - Ansökan - Skapa", - "editApplication": "Helsinforstillägg - Ansökan - Redigera", - "viewApplication": "Helsinforstillägg - Ansökan - Granska" + "login": "Helsingforstillägg - Logga in", + "home": "Helsingforstillägg - Framsida", + "createApplication": "Helsingforstillägg - Ansökan - Skapa", + "editApplication": "Helsingforstillägg - Ansökan - Redigera", + "viewApplication": "Helsingforstillägg - Ansökan - Granska", + "accessibilityStatement": "Helsingforstillägg - Tillgänglighetsförklaring" + }, + "accessibilityStatement": { + "h1": "Saavutettavuusseloste", + "sections": { + "section1": { + "heading1": "Digitaalinen työllisyyden Helsinki-lisä -asiointipalvelu", + "content1": "Tämä saavutettavuusseloste koskee digitaalista työllisyyden Helsinki-lisä -asiointipalvelua. Palvelun osoite on https://helsinki-lisa.hel.fi" + }, + "section2": { + "heading1": "Helsingin kaupungin tavoite", + "content1": "Digitaalisten palveluiden saavutettavuudessa Helsingin tavoitteena on pyrkiä vähintään WCAG ohjeiston mukaiseen AA- tai sitä parempaan tasoon, mikäli se on kohtuudella mahdollista.", + "content2": "Helsingin tavoitteena on pyrkiä digitaalisten palveluiden saavutettavuudessa vähintään WCAG-ohjeistuksen mukaiseen AA- tai sitä parempaan tasoon." + }, + "section3": { + "heading1": "Helsinki-lisä -asiointipalvelun saavutettavuuden tila", + "content1": "Helsinki-lisä -asiointipalvelu täyttää lain asettamat kriittiset saavutettavuusvaatimukset WCAG v2.1 -tason AA mukaisesti seuraavin havaituin puuttein.", + "heading2": "Ei-saavutettava sisältö, havaitut puutteet ja puutteiden korjaus", + "content2": "Jäljempänä on esitetty havaittuja vielä korjaamattomia puutteita.", + "list": { + "item1": { + "heading": "Ruudunlukuohjelman virheellinen kohdistusjärjestys", + "text": "Kun käyttäjä avaa uuden hakemuksen, ei ruudunlukuohjelman kohdistusta viedä lomakkeen alkuun, vaan kohdistus sijaitsee virheellisesti keskellä Työnantajan tiedot-osiota." + }, + "item2": { + "heading": "Nykyistä työvaihetta ei välitetä avustavalle teknologialle", + "text": "Lomakkeen työvaihelistauksesta ei välity teknistä informaatiota avustavalle teknologialle siitä, missä työvaiheessa käyttäjä parhaillaan on." + }, + "item3": { + "heading": "Useamman virheellisen lomakentän ilmoittaminen", + "text": "Palvelun virheilmoitukset esitetään useimmiten siten, että lomakkeen seuraavaan osioon siirtymisyrityksen yhteydessä käyttäjän selainkohdistus viedään lomakkeen ensimmäiseen virheelliseen lomakekenttään. Jos useammassa kentässä on virhe, muiden virheellisten kohtien löytäminen on hankalaa." + }, + "item4": { + "heading": "Virheilmoitusten tekstit liian yleisluontoisia", + "text": "Monissa kentissä käytettiin samaa virheilmoitusta eli ”Virheellinen arvo”. Tämä virheilmoitus on esim. Etunimi, Sukunimi, Lomaraha ja Työaika tuntia viikossa -kentissä." + }, + "item5": { + "heading": "Päivämäärien asettaminen ei onnistu tekstinsyöttökenttien avulla", + "text": "Ongelman vakavuutta pienentää se, että kalenteritoiminto on saavutettava ruudunlukuohjelmaa ja näppäimistöä käytettäessä." + } + }, + "heading3": "Puutteiden korjaus", + "content3": "Havaittuja saavutettavuuspuutteita pyritään korjaamaan jatkuvasti. Tässä selosteessa havaittujen saavutettavuuspuutteiden listausta päivitetään sen mukaan, kun puutteita saadaan korjattua." + }, + "section4": { + "heading1": "Saavutettavuusselosteen laatiminen", + "content1": "Tämä seloste on päivitetty 05.06.2023." + }, + "section5": { + "heading1": "Saavutettavuuden arviointi", + "content1": "Saavutettavuuden arvioinnissa on noudatettu Helsingin kaupungin työohjetta ja menetelmiä, jotka pyrkivät varmistamaan sivuston saavutettavuuden kaikissa työvaiheissa.", + "content2": "Saavutettavuus on tarkistettu ulkopuolisen asiantuntijan suorittamana auditointina. Ulkopuolisen asiantuntija-auditoinnin on suorittanut Siteimprov 23.3.2022.", + "content3": "Saavutettavuus on tarkistettu käyttäen ohjelmallista saavutettavuustarkistusta sekä sivuston ja sisällön manuaalista tarkistusta. Ohjelmallinen tarkistus on suoritettu käyttäen Siteimproven saavutettavuuden automaattista testaustyökalua ja selainlaajennusta.", + "content4": "Manuaalisessa testauksessa on käytetty Chrome- ja Firefox-selaimia, niiden 200% tiloja sekä tietoteknisiä apuvälineitä, kuten ruudunlukuohjelmia, ohjaimia ja erikoisnäppäimistöjä. Mobiilitestaus toteutettiin iOS- ja Android-käyttöjärjestelmillä ja niille tarkoitetuilla ruudunlukuohjelmilla." + }, + "section6": { + "heading1": "Saavutettavuusselosteen päivittäminen", + "content1": "Sivuston saavutettavuudesta huolehditaan jatkuvalla valvonnalla tekniikan tai sisällön muuttuessa, sekä määräajoin suoritettavalla tarkistuksella. Tätä selostetta päivitetään sivuston muutosten ja saavutettavuuden tarkistusten yhteydessä." + }, + "section7": { + "heading1": "Huomasitko puutteita saavutettavuudessa?", + "content1": "Pyrimme jatkuvasti parantamaan verkkopalvelun saavutettavuutta. Jos löydät ongelmia, joita ei ole kuvattu tällä sivulla, ilmoita niistä meille ja teemme parhaamme puutteiden korjaamiseksi.", + "content2": "Anna palautetta tällä verkkolomakkeella ", + "content3": "Palautekanavan kautta voit myös pyytää saavutettavaan muotoon muokattuja tietoja Hel.fi-sivuston sisällöstä." + }, + "section8": { + "heading1": "Tietojen pyytäminen saavutettavassa muodossa", + "content1": "Mikäli et koe saavasi sivuston sisältöä saavutettavassa muodossa, voit pyytää tietoja palautelomakkeella ", + "content1end": ". Tiedusteluun pyritään vastaamaan kohtuullisessa ajassa." + }, + "section9": { + "heading1": "Saavutettavuuden valvonta", + "content1": "Etelä-Suomen aluehallintovirasto valvoo saavutettavuusvaatimusten toteutumista. Jos et ole tyytyväinen saamaasi vastaukseen tai et saa vastausta lainkaan kahden viikon aikana, voit antaa palautteen Etelä-Suomen aluehallintovirastoon.", + "contact1": "Etelä-Suomen aluehallintovirasto,", + "contact2": "Saavutettavuuden valvonnan yksikkö", + "contact3": "www.saavutettavuusvaatimukset.fi", + "contact4": "saavutettavuus@avi.fi", + "content2": "Helsinki-lisä -asiointi palvelun saavutettavuusselosteesta vastaa Helsingin kaupungin työllisyyspalvelut -yksikkö." + }, + "section10": { + "heading1": "Helsingin kaupunki ja saavutettavuus", + "content1": "Kaupunki edistää digitaalisten palveluiden saavutettavuutta yhdenmukaistamalla julkaisutyötä ja järjestämällä saavutettavuuteen keskittyvää koulutusta henkilökunnalleen.", + "content2": "Sivustojen saavutettavuuden tasoa seurataan jatkuvasti sivustoja ylläpidettäessä. Havaittuihin puutteisiin reagoidaan välittömästi. Tarvittavat muutokset pyritään suorittamaan mahdollisimman nopeasti." + }, + "section11": { + "heading1": "Vammaiset ja avustavien teknologioiden käyttäjät", + "content1": "Kaupunki tarjoaa neuvontaa ja tukea vammaisille ja avustavien teknologioiden käyttäjille. Tukea on saatavilla kaupungin sivuilla ilmoitetuilta neuvontasivuilta sekä puhelinneuvonnasta." + }, + "section12": { + "heading1": "Palaute ja yhteystiedot" + } + } } } diff --git a/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.sc.ts b/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.sc.ts index 51df8058ab..8f2c7a4a9f 100644 --- a/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.sc.ts +++ b/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.sc.ts @@ -1,3 +1,4 @@ +import { respondAbove } from 'shared/styles/mediaQueries'; import styled, { DefaultTheme } from 'styled-components'; interface AvatarProps { @@ -10,17 +11,29 @@ export const $ListItemWrapper = styled.div` `; export const $ListItem = styled.li` - display: flex; + display: block; background-color: ${(props) => props.theme.colors.white}; padding: ${(props) => props.theme.spacing.xs}; justify-content: space-between; + + ${respondAbove('md')` + display: flex; + `}; `; export const $ItemContent = styled.div` display: grid; - grid-template-columns: 60px 3fr repeat(4, minmax(100px, 2fr)); grid-gap: ${(props) => props.theme.spacing.m}; width: 100%; + margin-bottom: var(--spacing-s); + + ${respondAbove('xs')` + grid-template-columns: 1fr 1fr; + `}; + + ${respondAbove('sm')` + grid-template-columns: 60px 3fr repeat(4, minmax(100px, 3fr)); + `}; `; export const $Avatar = styled.div` @@ -38,6 +51,10 @@ export const $Avatar = styled.div` width: 60px; min-height: 60px; min-width: 60px; + grid-column: 1 / -1; + ${respondAbove('sm')` + grid-column: 1; + `}; `; export const $DataColumn = styled.div` diff --git a/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.tsx b/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.tsx index 70da6bbf0b..b32ba4c379 100644 --- a/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.tsx +++ b/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.tsx @@ -6,6 +6,8 @@ import { Button, IconSpeechbubbleText, StatusLabel } from 'hds-react'; import React from 'react'; import LoadingSkeleton from 'react-loading-skeleton'; import { $GridCell } from 'shared/components/forms/section/FormSection.sc'; +import { respondAbove } from 'shared/styles/mediaQueries'; +import styled from 'styled-components'; import { $Avatar, @@ -23,6 +25,17 @@ import { export type ListItemProps = ApplicationListItemData | Loading; +const $StatusLabel = styled(StatusLabel)` + font-weight: 600; + text-align: center; + box-sizing: border-box; + max-width: 160px; + margin-right: var(--spacing-s); + ${respondAbove('sm')` + max-width: none; + `} +`; + const ListItem: React.FC = (props) => { const { t } = useTranslation(); const translationBase = 'common:applications.list'; @@ -99,15 +112,7 @@ const ListItem: React.FC = (props) => { <$DataHeader> {t(`${translationBase}.common.editEndDate`)} - - {editEndDate} - + <$StatusLabel type="alert">{editEndDate} )} diff --git a/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/DeMinimisAidForm.tsx b/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/DeMinimisAidForm.tsx index 878c6615a7..95dd9dae9a 100644 --- a/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/DeMinimisAidForm.tsx +++ b/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/DeMinimisAidForm.tsx @@ -1,5 +1,6 @@ import { DE_MINIMIS_AID_GRANTED_AT_MAX_DATE, + DE_MINIMIS_AID_GRANTED_AT_MIN_DATE, MAX_DEMINIMIS_AID_TOTAL_AMOUNT, } from 'benefit/applicant/constants'; import { DE_MINIMIS_AID_KEYS } from 'benefit-shared/constants'; @@ -108,6 +109,7 @@ const DeMinimisAidForm: React.FC = ({ data }) => { invalid={!!getErrorMessage(DE_MINIMIS_AID_KEYS.GRANTED_AT)} aria-invalid={!!getErrorMessage(DE_MINIMIS_AID_KEYS.GRANTED_AT)} errorText={getErrorMessage(DE_MINIMIS_AID_KEYS.GRANTED_AT)} + minDate={DE_MINIMIS_AID_GRANTED_AT_MIN_DATE} maxDate={DE_MINIMIS_AID_GRANTED_AT_MAX_DATE} required /> diff --git a/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/utils/validation.ts b/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/utils/validation.ts index a596b2ef30..d1bf2da925 100644 --- a/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/utils/validation.ts +++ b/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/utils/validation.ts @@ -1,10 +1,13 @@ -import { DE_MINIMIS_AID_GRANTED_AT_MAX_DATE } from 'benefit/applicant/constants'; +import { + DE_MINIMIS_AID_GRANTED_AT_MAX_DATE, + DE_MINIMIS_AID_GRANTED_AT_MIN_DATE, +} from 'benefit/applicant/constants'; import { DE_MINIMIS_AID_KEYS, VALIDATION_MESSAGE_KEYS, } from 'benefit-shared/constants'; import { DeMinimisAid } from 'benefit-shared/types/application'; -import isFuture from 'date-fns/isFuture'; +import { isBefore, isFuture } from 'date-fns'; import { TFunction } from 'next-i18next'; import { convertToUIDateFormat, parseDate } from 'shared/utils/date.utils'; import { getNumberValue } from 'shared/utils/string.utils'; @@ -42,5 +45,20 @@ export const getValidationSchema = (t: TFunction): Yup.SchemaOf => } return true; }, + }) + .test({ + message: t(VALIDATION_MESSAGE_KEYS.DATE_MIN, { + min: convertToUIDateFormat(DE_MINIMIS_AID_GRANTED_AT_MIN_DATE), + }), + test: (value) => { + if (!value) return false; + + const date = parseDate(value); + + if (date && isBefore(date, DE_MINIMIS_AID_GRANTED_AT_MIN_DATE)) { + return false; + } + return true; + }, }), }); diff --git a/frontend/benefit/applicant/src/components/applications/forms/application/step1/ApplicationFormStep1.tsx b/frontend/benefit/applicant/src/components/applications/forms/application/step1/ApplicationFormStep1.tsx index b73c2d15b9..8344d8b03a 100644 --- a/frontend/benefit/applicant/src/components/applications/forms/application/step1/ApplicationFormStep1.tsx +++ b/frontend/benefit/applicant/src/components/applications/forms/application/step1/ApplicationFormStep1.tsx @@ -161,56 +161,56 @@ const ApplicationFormStep1: React.FC = ({ /> - {showDeminimisSection && ( - - <$GridCell $colSpan={8}> - - <$RadioButton - id={`${fields.deMinimisAid.name}False`} - name={fields.deMinimisAid.name} - value="false" - label={t( - `${translationsBase}.fields.${APPLICATION_FIELDS_STEP1_KEYS.DE_MINIMIS_AID}.no` - )} - onChange={() => { - formik.setFieldValue(fields.deMinimisAid.name, false); - setDeMinimisAids([]); - }} - // 3 states: null (none is selected), true, false - checked={formik.values.deMinimisAid === false} - /> - <$RadioButton - id={`${fields.deMinimisAid.name}True`} - name={fields.deMinimisAid.name} - value="true" - label={t( - `${translationsBase}.fields.${APPLICATION_FIELDS_STEP1_KEYS.DE_MINIMIS_AID}.yes` - )} - onChange={() => - formik.setFieldValue(fields.deMinimisAid.name, true) - } - checked={formik.values.deMinimisAid === true} - /> - - + {showDeminimisSection && ( + + <$GridCell $colSpan={8}> + + <$RadioButton + id={`${fields.deMinimisAid.name}False`} + name={fields.deMinimisAid.name} + value="false" + label={t( + `${translationsBase}.fields.${APPLICATION_FIELDS_STEP1_KEYS.DE_MINIMIS_AID}.no` + )} + onChange={() => { + formik.setFieldValue(fields.deMinimisAid.name, false); + setDeMinimisAids([]); + }} + // 3 states: null (none is selected), true, false + checked={formik.values.deMinimisAid === false} + /> + <$RadioButton + id={`${fields.deMinimisAid.name}True`} + name={fields.deMinimisAid.name} + value="true" + label={t( + `${translationsBase}.fields.${APPLICATION_FIELDS_STEP1_KEYS.DE_MINIMIS_AID}.yes` + )} + onChange={() => + formik.setFieldValue(fields.deMinimisAid.name, true) + } + checked={formik.values.deMinimisAid === true} + /> + + - {formik.values.deMinimisAid && ( - <> - - - - )} - - )} + {formik.values.deMinimisAid && ( + <> + + + + )} + + )} <$GridCell $colSpan={8}> { - void onDelete(applicationId); - } + void onDelete(applicationId); + } : undefined; const clearDeminimisAids = React.useCallback((): void => { @@ -125,7 +125,7 @@ const useApplicationFormStep1 = ( const showDeminimisSection = values.associationHasBusinessActivities === true || - organizationType !== ORGANIZATION_TYPES.ASSOCIATION; + organizationType === ORGANIZATION_TYPES.COMPANY; const languageOptions = React.useMemo( (): OptionType[] => getLanguageOptions(t, 'languages'), diff --git a/frontend/benefit/applicant/src/components/applications/forms/application/step1/utils/validation.ts b/frontend/benefit/applicant/src/components/applications/forms/application/step1/utils/validation.ts index 8c1c491471..26ff48a0bc 100644 --- a/frontend/benefit/applicant/src/components/applications/forms/application/step1/utils/validation.ts +++ b/frontend/benefit/applicant/src/components/applications/forms/application/step1/utils/validation.ts @@ -138,6 +138,18 @@ export const getValidationSchema = ( then: Yup.boolean() .nullable() .required(t(VALIDATION_MESSAGE_KEYS.REQUIRED)), + }) + .test({ + message: t(VALIDATION_MESSAGE_KEYS.REQUIRED), + test: (val) => { + if ( + organizationType?.toLowerCase() === + ORGANIZATION_TYPES.COMPANY.toLowerCase() + ) { + return typeof val === 'boolean'; + } + return true; + }, }), [APPLICATION_FIELDS_STEP1_KEYS.DE_MINIMIS_AID_SET]: Yup.array().of( getDeminimisValidationSchema(t).nullable() diff --git a/frontend/benefit/applicant/src/components/applications/forms/application/step6/ApplicationFormStep6.tsx b/frontend/benefit/applicant/src/components/applications/forms/application/step6/ApplicationFormStep6.tsx index 1de3a508a1..e0b0fdeb25 100644 --- a/frontend/benefit/applicant/src/components/applications/forms/application/step6/ApplicationFormStep6.tsx +++ b/frontend/benefit/applicant/src/components/applications/forms/application/step6/ApplicationFormStep6.tsx @@ -58,11 +58,7 @@ const ApplicationFormStep6: React.FC< {data && ( <> <$GridCell $colSpan={12}> - + <$GridCell $colSpan={5} diff --git a/frontend/benefit/applicant/src/components/footer/Footer.tsx b/frontend/benefit/applicant/src/components/footer/Footer.tsx index 4579c02a86..d071a29bca 100644 --- a/frontend/benefit/applicant/src/components/footer/Footer.tsx +++ b/frontend/benefit/applicant/src/components/footer/Footer.tsx @@ -1,11 +1,13 @@ import { useTranslation } from 'benefit/applicant/i18n'; import { Footer } from 'hds-react'; import React from 'react'; +import useLocale from 'shared/hooks/useLocale'; import { $FooterWrapper } from './Footer.sc'; const FooterSection: React.FC = () => { const { t } = useTranslation(); + const locale = useLocale(); return ( <$FooterWrapper> @@ -18,7 +20,7 @@ const FooterSection: React.FC = () => { as="a" rel="noopener noreferrer" target="_blank" - href={t('common:footer.accessibilityStatementLink')} + href={`/${locale}/accessibility-statement`} label={t('common:footer.accessibilityStatement')} /> { <> ` + ${(props) => ` + background-color: ${props.theme.colors[props.backgroundColor]}; + `} display: flex; flex-direction: column; height: 100%; diff --git a/frontend/benefit/applicant/src/components/layout/Layout.tsx b/frontend/benefit/applicant/src/components/layout/Layout.tsx index 0ed02133e9..ed8012f59f 100644 --- a/frontend/benefit/applicant/src/components/layout/Layout.tsx +++ b/frontend/benefit/applicant/src/components/layout/Layout.tsx @@ -1,11 +1,12 @@ import Header from 'benefit/applicant/components/header/Header'; -import SupportingContent from 'benefit/applicant/components/supportingContent/SupportingContent'; import TermsOfService from 'benefit/applicant/components/termsOfService/TermsOfService'; import { IS_CLIENT, LOCAL_STORAGE_KEYS } from 'benefit/applicant/constants'; import dynamic from 'next/dynamic'; +import { useRouter } from 'next/router'; import * as React from 'react'; import useAuth from 'shared/hooks/useAuth'; +import { ROUTES } from '../../constants'; import { $Main } from './Layout.sc'; const Footer = dynamic( @@ -19,6 +20,8 @@ const Layout: React.FC = ({ children, ...rest }) => { const { isAuthenticated } = useAuth(); const [isTermsOfServiceApproved, setIsTermsOfSerivceApproved] = React.useState(false); + const router = useRouter(); + const bgColor = router.pathname === ROUTES.LOGIN ? 'silverLight' : 'white'; React.useEffect(() => { if (IS_CLIENT) { @@ -32,17 +35,14 @@ const Layout: React.FC = ({ children, ...rest }) => { }, []); return ( - <$Main {...rest}> + <$Main backgroundColor={bgColor} {...rest}>
{isAuthenticated && !isTermsOfServiceApproved ? ( ) : ( - <> - {children} - - + children )}