From 270b89b01ae870617f4f749b4e9c5f6dd7ad9b7d Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Wed, 4 Oct 2023 15:02:25 +0300 Subject: [PATCH 01/15] ID23336: removed k8s executor --- .../commands/_k8s_notebook_handler.py | 428 ------------------ .../aoi/management/commands/_notebook.py | 11 - .../management/commands/notebook_executor.py | 1 - .../commands/notebooks_executor_k8s.py | 27 -- .../aoi/management/commands/publisher_k8s.py | 28 -- 5 files changed, 495 deletions(-) delete mode 100644 webapplication/aoi/management/commands/_k8s_notebook_handler.py delete mode 100644 webapplication/aoi/management/commands/notebooks_executor_k8s.py delete mode 100644 webapplication/aoi/management/commands/publisher_k8s.py diff --git a/webapplication/aoi/management/commands/_k8s_notebook_handler.py b/webapplication/aoi/management/commands/_k8s_notebook_handler.py deleted file mode 100644 index c20a3417..00000000 --- a/webapplication/aoi/management/commands/_k8s_notebook_handler.py +++ /dev/null @@ -1,428 +0,0 @@ -import logging -import os -import shutil -import hashlib -from typing import Dict, List - -from kubernetes import client, config -from kubernetes.client.rest import ApiException - -from aoi.models import Component, Request -from aoi.management.commands.executor import NotebookExecutor -from aoi.management.commands._ComponentExecutionHelper import ComponentExecutionHelper - -from django.conf import settings -from django.utils.timezone import localtime - -logger = logging.getLogger(__name__) - - -class K8sNotebookHandler(ComponentExecutionHelper): - - def __init__(self, namespace: str) -> None: - config.load_incluster_config() - self.core_v1 = client.CoreV1Api() - self.batch_v1 = client.BatchV1Api() - self.delete_options = client.V1DeleteOptions() - self.namespace = namespace - self.component_validation_job_label = "component-validation" - self.component_execution_job_label = "component-execution" - self.notebook_execution_script = self.deliver_notebook_executor() - - @staticmethod - def get_file_md5(filepath: str) -> str: - """Create md5 hash of byte-read file - - Args: - filepath (str): Path to file - - Returns: - str: Result of md5 hash function - """ - - with open(filepath, 'rb') as file: - hashed_file = hashlib.md5(file.read()) - return hashed_file.hexdigest() - - @staticmethod - def deliver_notebook_executor() -> str: - """ Check if NotebookExecutor.py changed with md5 hash and last modified date. - Deliver NotebookExecutor.py script into volume that will be - shared with notebook execution job pods. - - Returns: - str: path to file with notebook execution script - """ - current_hash = '' - current_mod_data = 0 - - notebook_execution_file = os.path.join( - settings.NOTEBOOK_POD_DATA_VOLUME_MOUNT_PATH, - os.path.basename(NotebookExecutor.__file__) - ) - - if os.path.exists(notebook_execution_file): - current_hash = K8sNotebookHandler.get_file_md5( - notebook_execution_file) - current_mod_data = os.path.getmtime(notebook_execution_file) - - challenger_hash = K8sNotebookHandler.get_file_md5( - NotebookExecutor.__file__) - challenger_mod_date = os.path.getmtime(NotebookExecutor.__file__) - - if not (challenger_hash == current_hash and challenger_mod_date == current_mod_data): - shutil.copy2(NotebookExecutor.__file__, - settings.PERSISTENT_STORAGE_PATH) - logger.info( - 'File with notebook execution script have changed. File replaced') - - logger.info( - f'File with notebook execution script is up to date to "{notebook_execution_file}" ') - return notebook_execution_file - - def start_job(self, job: client.V1Job): - """Use API to start job in cluster - - Args: - job (client.V1Job): Description of Job to start - """ - try: - api_response = self.batch_v1.create_namespaced_job( - body=job, - namespace=self.namespace, - pretty=False - ) - logger.info( - f"Job created in namespace: '{self.namespace}'. status='{api_response.status}") - except ApiException as e: - logger.error( - f'Exception when calling BatchV1Api->create_namespaced_job: {e}\n') - - @staticmethod - def create_job_object( - image: str, - name: str, - labels: Dict[str, str], - command: List[str], - backofflimit: int = 6, - active_deadline_seconds: int = 36_000, - require_gpu=False, - environment: List[client.V1EnvVar] = None - - ) -> client.V1Job: - """StaticMethod. Creates job description. - - Args: - image (str): Docker image name and tag - name (str): Name of the future job - labels (Dict[str, str]): Labels for k8s objects - command (List[str]): Command to run in POD on startup - backofflimit (int, optional): The number of retries before considering - a Job as failed. Defaults to 6. - active_deadline_seconds (int, optional): Active deadline, once a Job reaches it, - all of its running Pods are terminated and the Job status will become type: - Failed with reason: DeadlineExceeded. Defaults to 36_000s (10 hours). - require_gpu (bool, optional): Whether or not job require GPU cores. Defaults to False - - Returns: - client.V1Job - """ - - gpu_resources = client.V1ResourceRequirements( - limits={ - "nvidia.com/gpu": str(settings.GPU_CORES_PER_NOTEBOOK) - } - ) - component_volume = client.V1Volume( - name='component-volume', - persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource( - claim_name='sip-data-pvc' - ) - ) - component_volume_mount = client.V1VolumeMount( - name='component-volume', - mount_path=settings.NOTEBOOK_POD_DATA_VOLUME_MOUNT_PATH, - read_only=False - ) - container = client.V1Container( - name=name, - image=image, - command=command, - env=environment, - security_context=client.V1SecurityContext( - run_as_user=0 - ), - volume_mounts=[ - component_volume_mount, - ], - # image_pull_policy='Always', - image_pull_policy='IfNotPresent', - resources=gpu_resources if require_gpu else None - ) - template = client.V1PodTemplateSpec( - metadata=client.V1ObjectMeta( - labels=labels, - - ), - spec=client.V1PodSpec( - containers=[container, ], - volumes=[component_volume, ], - restart_policy="Never", - image_pull_secrets=[ - { - 'name': settings.IMAGE_PULL_SECRETS, - }, - ], - ), - ) - spec = client.V1JobSpec( - template=template, - backoff_limit=backofflimit, - active_deadline_seconds=active_deadline_seconds - ) - job = client.V1Job( - api_version="batch/v1", - kind="Job", - metadata=client.V1ObjectMeta(name=name), - spec=spec - ) - logger.info("Job description created. Name: '%s'" % str(name)) - return job - - def start_component_validation(self) -> None: - """Method to retrieve not validated components, - create jobs to validate them and supervise results - """ - - label_selector = f'job_type={self.component_validation_job_label}' - jobs = self.batch_v1.list_namespaced_job( - namespace=self.namespace, label_selector=label_selector) - component_ids_list = [int(x.metadata.labels['component_id']) - for x in jobs.items] - not_validated_components = Component.objects.filter( - run_validation=False).exclude(id__in=component_ids_list) - logger.info( - f'Number of components to validate: {len(not_validated_components)}\n') - - for component in not_validated_components: - job_manifest = self.create_component_validation_job_manifest( - component) - self.start_job(job_manifest) - - def start_component_validation_jobs_supervision(self) -> None: - """Retrieve notebook validation jobs and check them """ - label_selector = f'job_type={self.component_validation_job_label}' - jobs = self.batch_v1.list_namespaced_job( - namespace=self.namespace, label_selector=label_selector) - for job in jobs.items: - self.supervise_component_validation_job(job) - - def create_component_validation_job_manifest(self, component: Component) -> client.V1Job: - """Method to create notebook validation job in k8s cluster - - Args: - notebook (JupyterNotebook): Notebook instance to validate - - Returns: - client.V1Job: - """ - return self.create_job_object( - image=component.image, - name=f'component-validation-{component.id}', - command=['python3', '--version', ], - labels={ - 'component_id': str(component.id), - 'job_type': self.component_validation_job_label - }, - backofflimit=settings.NOTEBOOK_JOB_BACKOFF_LIMIT, - active_deadline_seconds=settings.NOTEBOOK_VALIDATION_JOB_ACTIVE_DEADLINE, - require_gpu=component.run_on_gpu - ) - - def supervise_component_validation_job(self, job: client.V1Job): - """Method to check if job completed or failed, change notebook validation status accordingly and delete the job from cluster - - Args: - job (client.V1Job): The job to supervise - """ - if job.status.succeeded == 1 or job.status.failed == 1: - component = Component.objects.get( - id=job.metadata.labels['component_id']) - component.run_validation = True - component.success = bool(job.status.succeeded) - component.save() - logging.info( - f'Validation of component with id "{job.metadata.labels["component_id"]}" is finished') - self.delete_job(job) - - def delete_job(self, job: client.V1Job): - """Delete job from k8s cluster - - Args: - job (client.V1Job): The job to delete - """ - - try: - job_name = job.metadata.name - api_response = self.batch_v1.delete_namespaced_job( - job_name, - self.namespace, - grace_period_seconds=0, - propagation_policy='Background' - ) - logging.info("Job deleted. status='%s'" % str(api_response.status)) - except ApiException as e: - logging.error( - "Exception when calling BatchV1Api->delete_namespaced_job: %s\n" % e) - - def start_notebook_execution(self) -> None: - """Method to retrieve unfinished requests and start execution jobs""" - label_selector = f'job_type={self.component_execution_job_label}' - jobs = self.batch_v1.list_namespaced_job( - namespace=self.namespace, label_selector=label_selector) - number_requests_to_run = settings.NOTEBOOK_EXECUTOR_MAX_JOBS - \ - len(jobs.items) - logger.info(f'Available execution limit: {number_requests_to_run}') - - if number_requests_to_run <= 0: - return - - request_ids_list = [x.metadata.labels['request_id'] - for x in jobs.items] - not_executed_request = Request.objects.filter( - started_at__isnull=True, component__run_validation=True, component__success=True - ).exclude(id__in=request_ids_list).all()[:number_requests_to_run] - logger.info( - f'Number of requests to execute: {len(not_executed_request)}') - - if len(not_executed_request) == 0 and len(request_ids_list) == 0: - logger.info('All request executed') - return - - for request in not_executed_request: - job = self.create_component_execution_job_desc(request) - self.create_result_folder(request) - self.start_job(job) - request.started_at = localtime() - request.save() - - def start_component_execution_jobs_supervision(self) -> None: - """Retrieve notebook execution jobs and check them""" - label_selector = f'job_type={self.component_execution_job_label}' - jobs = self.batch_v1.list_namespaced_job( - namespace=self.namespace, label_selector=label_selector) - for job in jobs.items: - self.supervise_component_execution_job(job) - - def supervise_component_execution_job(self, job: client.V1Job): - """Method to supervise execution job, store results and delete job afterwards. - - Args: - job (client.V1Job): Job to supervise - """ - - job_labels = job.metadata.labels - pod_label_selector = f'controller-uid={job_labels["controller-uid"]}' - logger.info(f"Start supervising job {job_labels['request_id']}") - if job.status.succeeded == 1: - pod_result = self.get_results_from_pods(pod_label_selector) - request = Request.objects.get(id=job_labels['request_id']) - request.calculated = True - request.save() - self.delete_job(job) - - if job.status.conditions is not None and job.status.conditions[0].type == 'Failed': - request = Request.objects.get(id=job_labels['request_id']) - if job.status.conditions[0].message: - request.error = job.status.conditions[0].message - request.save() - self.delete_job(job) - - if job.status.failed in (1, 2): - pod_result = self.get_results_from_pods(pod_label_selector) - request = Request.objects.get(id=job_labels['request_id']) - request.finished_at = pod_result['finished_at'] - if pod_result['reason'] == 'Error': - request.error = pod_result['pod_log'] - logger.error(f"Job Error: {pod_result['pod_log']}") - request.save() - self.delete_job(job) - - def get_results_from_pods(self, pod_label_selector: str) -> Dict[str, str]: - """Retrieve job execution results from pod. This is needed to store broad results about notebook execution to request - - Args: - pod_label_selector (str): Label to determinate pod on which job is running - - Returns: - Dict[str, str]: Example: - { - 'pod_log': '', - 'exit_code': 0, - 'finished_at': datetime.datetime(2022, 10, 30, 9, 59, 8, tzinfo=tzlocal()), - 'reason': 'Completed' - } - """ - - exit_code = None - finished_at = None - reason = None - - pods_list = self.core_v1.list_namespaced_pod(namespace=self.namespace, - label_selector=pod_label_selector, - timeout_seconds=10) - pod_name = pods_list.items[0].metadata.name - pod_state = pods_list.items[0].status.container_statuses[0].state - pod_log_response = self.core_v1.read_namespaced_pod_log(name=pod_name, - namespace=self.namespace, - _return_http_data_only=True, - _preload_content=False - ) - pod_log = pod_log_response.data.decode("utf-8") - if pod_state.terminated is not None: - exit_code = pod_state.terminated.exit_code - reason = pod_state.terminated.reason - - pod_result = dict(pod_log=pod_log, - exit_code=exit_code, - finished_at=finished_at, - reason=reason - ) - return pod_result - - @staticmethod - def get_environment(request:Request) -> List[client.V1EnvVar]: - """Return environment variables for k8s pod as list - - Args: - request (Request): - - Returns: - List[client.V1EnvVar]: - """ - env_dict = super(K8sNotebookHandler, K8sNotebookHandler).get_environment(request) - return [client.V1EnvVar(key, value) for key, value in env_dict.items()] - - def create_component_execution_job_desc(self, request: Request) -> client.V1Job: - """Create execution job description object from request - - Args: - request (Request): Request to make job description from - - Returns: - client.V1Job: - """ - return self.create_job_object( - image=request.component.image, - name=f'execute-notebook-{str(request.id)}', - labels={'request_id': str( - request.id), 'job_type': self.component_execution_job_label}, - command=self.get_command( - request.component, - self.notebook_execution_script - ), - backofflimit=settings.NOTEBOOK_JOB_BACKOFF_LIMIT, - active_deadline_seconds=settings.NOTEBOOK_EXECUTION_TIMEOUT, - require_gpu=request.component.run_on_gpu, - environment=self.get_environment(request) - ) diff --git a/webapplication/aoi/management/commands/_notebook.py b/webapplication/aoi/management/commands/_notebook.py index 27b4a6f9..d4e57ceb 100644 --- a/webapplication/aoi/management/commands/_notebook.py +++ b/webapplication/aoi/management/commands/_notebook.py @@ -12,8 +12,6 @@ from aoi.management.commands._Container import (Container, ContainerValidator, ContainerExecutor, ) -from aoi.management.commands._k8s_notebook_handler import K8sNotebookHandler - from django.utils.timezone import localtime from django.core import management from django.core.mail import send_mail @@ -277,12 +275,3 @@ def publish_results(): success_requests.update(finished_at=localtime(), success=True) -class NotebookK8sThread(StoppableThread): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.notebook_handler = K8sNotebookHandler(settings.K8S_NAME_SPACE) - - def do_stuff(self): - # Execution - self.notebook_handler.start_notebook_execution() - self.notebook_handler.start_component_execution_jobs_supervision() diff --git a/webapplication/aoi/management/commands/notebook_executor.py b/webapplication/aoi/management/commands/notebook_executor.py index 94a8c8d0..82eb3789 100644 --- a/webapplication/aoi/management/commands/notebook_executor.py +++ b/webapplication/aoi/management/commands/notebook_executor.py @@ -4,7 +4,6 @@ from aoi.management.commands._notebook import ( NotebookDockerThread, PublisherThread, - NotebookK8sThread ) from multiprocessing import Process from django.core.management.base import BaseCommand diff --git a/webapplication/aoi/management/commands/notebooks_executor_k8s.py b/webapplication/aoi/management/commands/notebooks_executor_k8s.py deleted file mode 100644 index 4a11fb99..00000000 --- a/webapplication/aoi/management/commands/notebooks_executor_k8s.py +++ /dev/null @@ -1,27 +0,0 @@ -import logging -from multiprocessing import Process -from aoi.management.commands._notebook import ( - NotebookK8sThread -) - -from multiprocessing import Process -from django.core.management.base import BaseCommand - - -logger = logging.getLogger(__name__) - -class Command(BaseCommand): - help = "Manage running and validating of Jupyter Notebooks in k8s cluster command" - - def handle(self, *args, **options): - exitcode = None - while exitcode == 2 or exitcode is None: - child_process = Process(target=self.run, daemon=True) - child_process.start() - child_process.join() - exitcode = child_process.exitcode - - def run(self): - thread = NotebookK8sThread(daemon=True) - thread.start() - thread.join() \ No newline at end of file diff --git a/webapplication/aoi/management/commands/publisher_k8s.py b/webapplication/aoi/management/commands/publisher_k8s.py deleted file mode 100644 index c3e8a536..00000000 --- a/webapplication/aoi/management/commands/publisher_k8s.py +++ /dev/null @@ -1,28 +0,0 @@ -import logging -from multiprocessing import Process -from aoi.management.commands._notebook import ( - PublisherThread -) - -from multiprocessing import Process -from django.core.management.base import BaseCommand - - -logger = logging.getLogger(__name__) - -class Command(BaseCommand): - help = "Manage running publisher in k8s cluster command" - - def handle(self, *args, **options): - exitcode = None - while exitcode == 2 or exitcode is None: - child_process = Process(target=self.run, daemon=True) - child_process.start() - child_process.join() - exitcode = child_process.exitcode - - def run(self): - thread = PublisherThread(daemon=True) - thread.start() - thread.join() - \ No newline at end of file From f29b895d7057b6a9d71ed9a71d8afac122b55b79 Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Mon, 16 Oct 2023 18:05:09 +0300 Subject: [PATCH 02/15] ID23336: updated settings --- webapplication/sip/settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/webapplication/sip/settings.py b/webapplication/sip/settings.py index 37a939ae..07dc1f9f 100644 --- a/webapplication/sip/settings.py +++ b/webapplication/sip/settings.py @@ -243,8 +243,6 @@ NOTEBOOK_EXECUTION_ENVIRONMENT = os.getenv("NOTEBOOK_EXECUTION_ENVIRONMENT", "docker") NOTEBOOK_JOB_BACKOFF_LIMIT = int(os.getenv("NOTEBOOK_JOB_BACKOFF_LIMIT", 1)) NOTEBOOK_VALIDATION_JOB_ACTIVE_DEADLINE = int(os.getenv('NOTEBOOK_VALIDATION_JOB_ACTIVE_DEADLINE', 3000)) # 5 minutes -K8S_NAME_SPACE = os.getenv('K8S_NAME_SPACE','sip') -IMAGE_PULL_SECRETS = os.getenv('IMAGE_PULL_SECRETS', 'regcred') NOTEBOOK_EXECUTOR_MAX_JOBS = int(os.getenv('NOTEBOOK_EXECUTOR_MAX_JOBS', 2)) NOTEBOOK_POD_DATA_VOLUME_MOUNT_PATH = os.getenv('NOTEBOOK_POD_DATA_VOLUME_MOUNT_PATH', '/home/jovyan/work') GPU_CORES_PER_NOTEBOOK = int(os.getenv('GPU_CORES_PER_NOTEBOOK', 1)) From 83c13b509a287f556648efde31d3f4544e2c6328 Mon Sep 17 00:00:00 2001 From: Seneckiy Date: Thu, 2 Nov 2023 16:38:50 +0200 Subject: [PATCH 03/15] add geoap_email_notifications --- .../aoi/management/commands/_notebook.py | 68 ++++++------------- 1 file changed, 20 insertions(+), 48 deletions(-) diff --git a/webapplication/aoi/management/commands/_notebook.py b/webapplication/aoi/management/commands/_notebook.py index 27b4a6f9..553b8543 100644 --- a/webapplication/aoi/management/commands/_notebook.py +++ b/webapplication/aoi/management/commands/_notebook.py @@ -24,6 +24,19 @@ THREAD_SLEEP = 10 + +def email_notification(request, aoi, status): + try: + from geoap_email_notifications.utils import email_notification + email_notification(request, status) + except ModuleNotFoundError: + # Error handling + logger.info( + f"""Your request for AOI '{aoi.name if aoi else request.polygon.wkt}' + and layer '{request.component_name}' is succeeded""" + ) + + def clean_container_logs(logs): # Remove line numbers # like from this "00m ValueError("Images not loaded for given AOI." @@ -39,49 +52,6 @@ def clean_container_logs(logs): log_text = re.sub(r'\s+', ' ', log_text) return log_text -def send_email_notification(user_mail, email_message, subject): - result = 0 - try: - result = send_mail(subject, email_message, None, [user_mail]) - except Exception as ex: - logger.error(f"Error while sending mail: {str(ex)}") - if result == 1: - logger.info(f"Email sent successfully! for email '{user_mail}'") - else: - logger.info(f"Failed to send the email for email '{user_mail}'") - -def email_notification(request, status): - user_data = User.objects.filter(id=request.user_id).first() - aoi_name = AoI.objects.filter(id=request.aoi_id).first() - if not user_data.receive_notification: - logger.info(f"Not sending email for user '{user_data.email}'") - return - - message = f"""Your request for AOI '{aoi_name.name if aoi_name else request.polygon.wkt}' and layer '{request.component_name}' is {status} - \n\nClick the link below to visit the site:\n{request.request_origin}""" - send_email_notification(user_data.email, message, settings.EMAIL_SUBJECT) - - if settings.DEFAULT_SYSTEM_NOTIFICATION_EMAIL: - system_message=f""" - Status: {status.upper()}, - Error: {', '.join(request.user_readable_errors) if request.user_readable_errors else request.error}, - Domain: {request.request_origin}, - - AoI Name: {aoi_name.name if aoi_name else None}, - AoI polygon: {request.polygon.wkt}, - Component name: {request.component_name}, - Start date: {request.date_from.strftime("%Y/%m/%d") if request.date_from else None}, - End date: {request.date_to.strftime("%Y/%m/%d") if request.date_to else None}, - Additional parameter value: {request.additional_parameter}, - - User name: {user_data.username}, - User email: {user_data.email} - """ - send_email_notification(settings.DEFAULT_SYSTEM_NOTIFICATION_EMAIL, system_message, f"{settings.EMAIL_SUBJECT} - {status.upper()}") - - - - class StoppableThread(ABC, Thread): def __init__(self, *args, **kwargs): @@ -171,6 +141,7 @@ def execute_notebook(self): for container in exited_containers: attrs = Container.container_attrs(container) request = Request.objects.get(pk=attrs['pk']) + aoi = AoI.objects.filter(id=request.aoi_id).first() if attrs['exit_code'] == 0: logger.info(f"Notebook in container {container.name} executed successfully") request.calculated = True @@ -207,8 +178,7 @@ def execute_notebook(self): with transaction.atomic(): request_transaction.save(update_fields=("rolled_back", "completed", "error")) request_transaction.user.save(update_fields=("on_hold",)) - - email_notification(request, "failed") + email_notification(request, aoi, "failed") try: container.remove() except: @@ -241,8 +211,8 @@ def execute_notebook(self): request_transaction.save(update_fields=("rolled_back",)) request_transaction.user.save(update_fields=("on_hold",)) request.save(update_fields=['finished_at']) - - email_notification(request, "failed") + aoi = AoI.objects.filter(id=request.aoi_id).first() + email_notification(request, aoi, "failed") except Exception as ex: logger.error(f"Cannot update request {request.pk} in db: {str(ex)}") @@ -273,7 +243,9 @@ def publish_results(): request_transaction.save(update_fields=("completed",)) request_transaction.user.save(update_fields=("balance", "on_hold")) - email_notification(sr, "succeeded") + aoi = AoI.objects.filter(id=sr.aoi_id).first() + email_notification(sr, aoi, "succeeded") + success_requests.update(finished_at=localtime(), success=True) From b2deb19fff0bce2023f55ed4fbcc621358427ac1 Mon Sep 17 00:00:00 2001 From: Seneckiy Date: Fri, 3 Nov 2023 12:26:22 +0200 Subject: [PATCH 04/15] remove logs --- .../aoi/management/commands/_notebook.py | 20 ++++++------------- webapplication/sip/settings.py | 5 ----- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/webapplication/aoi/management/commands/_notebook.py b/webapplication/aoi/management/commands/_notebook.py index 553b8543..187e2e62 100644 --- a/webapplication/aoi/management/commands/_notebook.py +++ b/webapplication/aoi/management/commands/_notebook.py @@ -6,6 +6,7 @@ from threading import Thread, Lock, Event from django.db import transaction +from django.apps import apps from aoi.models import Component, Request, AoI, TransactionErrorMessage from user.models import User, Transaction @@ -25,16 +26,10 @@ THREAD_SLEEP = 10 -def email_notification(request, aoi, status): - try: +def email_notification(request, status): + if apps.is_installed("geoap_email_notifications"): from geoap_email_notifications.utils import email_notification email_notification(request, status) - except ModuleNotFoundError: - # Error handling - logger.info( - f"""Your request for AOI '{aoi.name if aoi else request.polygon.wkt}' - and layer '{request.component_name}' is succeeded""" - ) def clean_container_logs(logs): @@ -141,7 +136,6 @@ def execute_notebook(self): for container in exited_containers: attrs = Container.container_attrs(container) request = Request.objects.get(pk=attrs['pk']) - aoi = AoI.objects.filter(id=request.aoi_id).first() if attrs['exit_code'] == 0: logger.info(f"Notebook in container {container.name} executed successfully") request.calculated = True @@ -178,7 +172,7 @@ def execute_notebook(self): with transaction.atomic(): request_transaction.save(update_fields=("rolled_back", "completed", "error")) request_transaction.user.save(update_fields=("on_hold",)) - email_notification(request, aoi, "failed") + email_notification(request, "failed") try: container.remove() except: @@ -211,8 +205,7 @@ def execute_notebook(self): request_transaction.save(update_fields=("rolled_back",)) request_transaction.user.save(update_fields=("on_hold",)) request.save(update_fields=['finished_at']) - aoi = AoI.objects.filter(id=request.aoi_id).first() - email_notification(request, aoi, "failed") + email_notification(request, "failed") except Exception as ex: logger.error(f"Cannot update request {request.pk} in db: {str(ex)}") @@ -243,8 +236,7 @@ def publish_results(): request_transaction.save(update_fields=("completed",)) request_transaction.user.save(update_fields=("balance", "on_hold")) - aoi = AoI.objects.filter(id=sr.aoi_id).first() - email_notification(sr, aoi, "succeeded") + email_notification(sr, "succeeded") success_requests.update(finished_at=localtime(), success=True) diff --git a/webapplication/sip/settings.py b/webapplication/sip/settings.py index 37a939ae..f5b72ca9 100644 --- a/webapplication/sip/settings.py +++ b/webapplication/sip/settings.py @@ -249,15 +249,10 @@ NOTEBOOK_POD_DATA_VOLUME_MOUNT_PATH = os.getenv('NOTEBOOK_POD_DATA_VOLUME_MOUNT_PATH', '/home/jovyan/work') GPU_CORES_PER_NOTEBOOK = int(os.getenv('GPU_CORES_PER_NOTEBOOK', 1)) -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' -DEFAULT_FROM_EMAIL = 'noreply@geoap.quantumobile.com' -EMAIL_SUBJECT = 'Geoap Notification' - TRIAL_PERIOD_IN_DAYS = 30 TRIAL_PERIOD_BALANCE = 100 TRIAL_PERIOD_START_COMMENT = 'Started trial period' TRIAL_PERIOD_FINISH_COMMENT = 'Finished trial period' -DEFAULT_SYSTEM_NOTIFICATION_EMAIL = "" DEFAULT_TRANSACTION_ERROR = "Something went wrong, please contact us" From 838e41b8750799cf4a59c2d774808c4c7c6a5be8 Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Tue, 7 Nov 2023 10:32:01 +0200 Subject: [PATCH 05/15] ID23614: Added base for Move user-management --- webapplication/aoi/admin.py | 8 +- .../aoi/management/commands/_notebook.py | 100 +++++++++++------- .../0037_delete_transactionerrormessage.py | 16 +++ webapplication/aoi/models.py | 4 - webapplication/aoi/tests.py | 76 +------------ webapplication/aoi/views.py | 43 +++----- webapplication/sip/settings.py | 16 +-- webapplication/sip/urls.py | 2 +- webapplication/user/admin.py | 54 +--------- .../user/fixtures/transaction_fixtures.json | 41 ------- .../user/management/commands/check_trial.py | 24 ----- .../migrations/0024_delete_transaction.py | 15 +++ .../migrations/0025_auto_20231023_1435.py | 17 +++ webapplication/user/models.py | 66 +----------- webapplication/user/serializers.py | 10 +- webapplication/user/tests.py | 85 --------------- webapplication/user/urls.py | 7 -- webapplication/user/views.py | 23 +--- 18 files changed, 142 insertions(+), 465 deletions(-) create mode 100644 webapplication/aoi/migrations/0037_delete_transactionerrormessage.py delete mode 100644 webapplication/user/fixtures/transaction_fixtures.json delete mode 100644 webapplication/user/management/commands/check_trial.py create mode 100644 webapplication/user/migrations/0024_delete_transaction.py create mode 100644 webapplication/user/migrations/0025_auto_20231023_1435.py delete mode 100644 webapplication/user/urls.py diff --git a/webapplication/aoi/admin.py b/webapplication/aoi/admin.py index 67fdfa69..ffbae3f0 100644 --- a/webapplication/aoi/admin.py +++ b/webapplication/aoi/admin.py @@ -1,5 +1,5 @@ from django.contrib.gis import admin -from .models import AoI, Component, Request, TransactionErrorMessage +from .models import AoI, Component, Request from .forms import ComponentAdminForm @@ -28,9 +28,3 @@ class RequestAdmin(admin.OSMGeoAdmin): 'calculated', 'success', 'error', 'additional_parameter', 'user_readable_errors') readonly_fields = ['pk', 'started_at', 'calculated', 'error', ] - - -@admin.register(TransactionErrorMessage) -class TransactionErrorMessageAdmin(admin.OSMGeoAdmin): - list_display = ('user_readable_error', 'original_component_error') - readonly_fields = [] \ No newline at end of file diff --git a/webapplication/aoi/management/commands/_notebook.py b/webapplication/aoi/management/commands/_notebook.py index 27b4a6f9..c03eb0b3 100644 --- a/webapplication/aoi/management/commands/_notebook.py +++ b/webapplication/aoi/management/commands/_notebook.py @@ -7,8 +7,10 @@ from django.db import transaction -from aoi.models import Component, Request, AoI, TransactionErrorMessage -from user.models import User, Transaction +from aoi.models import Component, Request, AoI + +from user.models import User + from aoi.management.commands._Container import (Container, ContainerValidator, ContainerExecutor, ) @@ -19,11 +21,55 @@ from django.core.mail import send_mail from django.conf import settings from django.contrib.postgres.fields import ArrayField +from django.apps import apps + logger = logging.getLogger(__name__) THREAD_SLEEP = 10 + +from django.apps import apps + + + +def success_complete_transaction(success_request): + request_transaction = success_request.transactions.first() + if request_transaction: + request_transaction.completed = True + request_transaction.user.on_hold -= abs(request_transaction.amount) + request_transaction.user.balance -= abs(request_transaction.amount) + with transaction.atomic(): + request_transaction.save(update_fields=("completed",)) + request_transaction.user.save(update_fields=("balance", "on_hold")) + +def failed_request_transaction(failed_request): + from user_management.models import Transaction + request_transaction = failed_request.transactions.first() + request_transaction.user.on_hold -= abs(request_transaction.amount) + request_transaction.rolled_back = True + + request_transaction.completed = True + request_transaction.error = Transaction.generate_error(failed_request.user_readable_errors) + with transaction.atomic(): + request_transaction.save(update_fields=("rolled_back", "completed", "error")) + request_transaction.user.save(update_fields=("on_hold",)) + +def user_readable_error(request_with_error): + from user_management.models import TransactionErrorMessage + known_errors = [error.original_component_error for error in TransactionErrorMessage.objects.all()] + errors = [] + for error in known_errors: + if error in request_with_error.error: + errors.append(TransactionErrorMessage.objects.get(original_component_error=error).user_readable_error) + if errors: + request_with_error.user_readable_errors = errors + request_with_error.save(update_fields=['user_readable_errors']) + logger.info("Known error added") + else: + logger.info("No known error for component error") +######### + def clean_container_logs(logs): # Remove line numbers # like from this "00m ValueError("Images not loaded for given AOI." @@ -186,28 +232,12 @@ def execute_notebook(self): request.error = collected_error[len(collected_error) - error_max_length:] else: request.error = collected_error - known_errors = [error.original_component_error for error in TransactionErrorMessage.objects.all()] - errors = [] - for error in known_errors: - if error in request.error: - errors.append(TransactionErrorMessage.objects.get(original_component_error=error).user_readable_error) - if errors: - request.user_readable_errors = errors - request.save(update_fields=['user_readable_errors']) - logger.info("Known error added") - else: - logger.info("No known error for component error") request.save(update_fields=['error']) - - request_transaction = request.transactions.first() - request_transaction.user.on_hold -= abs(request_transaction.amount) - request_transaction.rolled_back = True - request_transaction.completed = True - request_transaction.error = Transaction.generate_error(request.user_readable_errors) - with transaction.atomic(): - request_transaction.save(update_fields=("rolled_back", "completed", "error")) - request_transaction.user.save(update_fields=("on_hold",)) - + + if apps.is_installed("user_management"): + user_readable_error(request) + failed_request_transaction(request) + email_notification(request, "failed") try: container.remove() @@ -233,15 +263,12 @@ def execute_notebook(self): logger.exception(f"Request {request.pk}, notebook {request.component.name}:") try: with transaction.atomic(): - request_transaction = request.transactions.first() - request_transaction.user.on_hold -= abs(request_transaction.amount) - request_transaction.rolled_back = True request.finished_at = localtime() - - request_transaction.save(update_fields=("rolled_back",)) - request_transaction.user.save(update_fields=("on_hold",)) request.save(update_fields=['finished_at']) + if apps.is_installed("user_management"): + failed_request_transaction(request) + email_notification(request, "failed") except Exception as ex: logger.error(f"Cannot update request {request.pk} in db: {str(ex)}") @@ -264,16 +291,11 @@ def publish_results(): success_requests = Request.objects.filter(calculated=True, success=False) logger.info(f"Marking requests {[sr.pk for sr in success_requests]} as succeeded") for sr in success_requests: - request_transaction = sr.transactions.first() - if request_transaction: - request_transaction.completed = True - request_transaction.user.on_hold -= abs(request_transaction.amount) - request_transaction.user.balance -= abs(request_transaction.amount) - with transaction.atomic(): - request_transaction.save(update_fields=("completed",)) - request_transaction.user.save(update_fields=("balance", "on_hold")) - - email_notification(sr, "succeeded") + + if apps.is_installed("user_management"): + success_complete_transaction(sr) + + email_notification(sr, "succeeded") success_requests.update(finished_at=localtime(), success=True) diff --git a/webapplication/aoi/migrations/0037_delete_transactionerrormessage.py b/webapplication/aoi/migrations/0037_delete_transactionerrormessage.py new file mode 100644 index 00000000..e48688fa --- /dev/null +++ b/webapplication/aoi/migrations/0037_delete_transactionerrormessage.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1 on 2023-10-23 13:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('aoi', '0036_component_detail_description_link'), + ] + + operations = [ + migrations.DeleteModel( + name='TransactionErrorMessage', + ), + ] diff --git a/webapplication/aoi/models.py b/webapplication/aoi/models.py index 66790c97..9beef523 100644 --- a/webapplication/aoi/models.py +++ b/webapplication/aoi/models.py @@ -132,7 +132,3 @@ class Request(models.Model): def component_name(self): return self.component.name - -class TransactionErrorMessage(models.Model): - user_readable_error = models.CharField(max_length=400, blank=True, null=True, verbose_name='User-readable Error Message') - original_component_error = models.CharField(max_length=400, blank=True, null=True, unique=True, verbose_name='Original component "error" example') diff --git a/webapplication/aoi/tests.py b/webapplication/aoi/tests.py index 6bddaf93..31e19f99 100644 --- a/webapplication/aoi/tests.py +++ b/webapplication/aoi/tests.py @@ -9,6 +9,7 @@ from .models import AoI, Component, Request from .serializers import AoISerializer from user.tests import UserBase +from django.apps import apps logger = logging.getLogger('root') @@ -588,81 +589,6 @@ def get_request_list(self): url = reverse('aoi:request_list_or_create') return self.client.get(url) - def test_request_price_and_user_balance_calculation(self): - self.client.force_login(self.staff_user) - target_request_price = Decimal('3685.01') - response = self.create_request(self.data_create) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - request_id = response.data.get("id", None) - component_id = response.data.get("notebook", None) - aoi_id = response.data.get("aoi", None) - self.assertIsNotNone(request_id) - self.assertIsNotNone(component_id) - self.assertIsNotNone(aoi_id) - - request = Request.objects.get(pk=request_id) - aoi = AoI.objects.get(pk=aoi_id) - component = Component.objects.get(pk=component_id) - transaction = request.transactions.first() - - calculated_price = component.calculate_request_price( - area=aoi.area_in_sq_km, - user=request.user - ) - - self.assertIsNotNone(transaction) - self.assertEqual(calculated_price, target_request_price) - self.assertEqual(abs(transaction.amount), target_request_price) - self.assertEqual(request.user.on_hold, target_request_price) - - def test_creating_request_error(self): - self.client.force_login(self.all_results_no_acl_user) - target_response = { - "non_field_errors": [ - f"Your actual balance is {self.all_results_no_acl_user.actual_balance}. " - f"It’s not enough to run the request. Please replenish the balance. " - f"Contact support (support@soilmate.ai)" - ] - } - data_create = { - 'user': 1005, - 'aoi': 1001, - 'notebook': 1001, - 'polygon': '' - } - response = self.create_request(data_create) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, target_response) - - def test_request_price_calculation(self): - self.client.force_login(self.staff_user) - target_response = { - 'id': None, - 'user': 1001, - 'aoi': 1001, - 'notebook': 1001, - 'notebook_name': 'JupyterNotebook_test', - 'date_from': None, - 'date_to': None, - 'started_at': None, - 'finished_at': None, - 'error': None, - 'calculated': False, - 'success': False, - 'polygon': 'SRID=4326;POLYGON ((36.01678367017178 50.14982647696019, 36.55073998712133 50.13673931232907, ' - '36.55073998712133 49.42479755639633, 36.02725340187668 49.41171039176521, 36.01678367017178 ' - '50.14982647696019))', - 'additional_parameter': None, - 'price': Decimal('3685.01'), - 'request_origin': 'http://testserver/', - 'user_readable_errors': None - } - data_create = {**self.data_create, 'pre_submit': True} - - response = self.create_request(data_create) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, target_response) - class AOIRequestsTestCase(UserBase): fixtures = ['user/fixtures/user_fixtures.json', diff --git a/webapplication/aoi/views.py b/webapplication/aoi/views.py index 0f97063b..cd8214eb 100644 --- a/webapplication/aoi/views.py +++ b/webapplication/aoi/views.py @@ -1,6 +1,5 @@ from django.conf import settings from django.contrib.gis.geos import GEOSGeometry, GEOSException -from django.db import transaction from django.utils.translation import gettext_lazy as _ from rest_framework import status from rest_framework.serializers import ValidationError, as_serializer_error @@ -14,10 +13,12 @@ from .serializers import AoISerializer, ComponentSerializer, RequestSerializer from user.permissions import ModelPermissions, IsOwnerPermission from .permissions import AoIIsOwnerPermission -from user.models import User, Transaction +from user.models import User from allauth.account import app_settings from allauth.utils import build_absolute_uri +from django.apps import apps + class AoIListCreateAPIView(ListCreateAPIView): """ @@ -180,14 +181,6 @@ def get_area_in_sq_km(self, validated_data): area = AoI.polygon_in_sq_km(polygon) return area - def create_transaction(self, user, amount, request): - Transaction.objects.create( - user=user, - amount=-amount, - request=request, - ) - user.on_hold += amount - user.save(update_fields=("on_hold",)) def create(self, request, *args, **kwargs): request_data = request.data.copy() @@ -210,26 +203,16 @@ def create(self, request, *args, **kwargs): "location is invalid")) return Response(as_serializer_error(validation_error), status=status.HTTP_400_BAD_REQUEST) - request_price = component.calculate_request_price( - user=request.user, - area=area - ) - if serializer.validated_data.get("pre_submit"): - self.perform_create(serializer) - return Response({**serializer.data, "price": request_price}, status=status.HTTP_200_OK) - user_actual_balance = request.user.actual_balance - if user_actual_balance < request_price: - validation_error = ValidationError(_(f"Your actual balance is {request.user.actual_balance}. " - f"It’s not enough to run the request. Please replenish the balance. " - f"Contact support (support@soilmate.ai)")) - return Response(as_serializer_error(validation_error), status=status.HTTP_400_BAD_REQUEST) - with transaction.atomic(): - self.perform_create(serializer) - self.create_transaction( - user=request.user, - amount=request_price, - request=serializer.instance, - ) + if apps.is_installed("user_management"): + from user_management.utils import create_transaction_and_check_money_amount + result = create_transaction_and_check_money_amount(request, area, component, serializer) + if result.get("error", ""): + return Response(as_serializer_error(result['error']), status=status.HTTP_400_BAD_REQUEST) + if result.get("request_price", ""): + self.perform_create(serializer) + return Response({**serializer.data, "price": result['request_price']}, status=status.HTTP_200_OK) + + self.perform_create(serializer) if not serializer.instance: validation_error = ValidationError(_("Error while creating a report")) return Response(as_serializer_error(validation_error), status=status.HTTP_400_BAD_REQUEST) diff --git a/webapplication/sip/settings.py b/webapplication/sip/settings.py index 37a939ae..2c71936e 100644 --- a/webapplication/sip/settings.py +++ b/webapplication/sip/settings.py @@ -59,7 +59,8 @@ # Local Apps 'user', 'publisher', - 'aoi' + 'aoi', + # 'user_management' ] REST_FRAMEWORK = { @@ -252,12 +253,13 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEFAULT_FROM_EMAIL = 'noreply@geoap.quantumobile.com' EMAIL_SUBJECT = 'Geoap Notification' - -TRIAL_PERIOD_IN_DAYS = 30 -TRIAL_PERIOD_BALANCE = 100 -TRIAL_PERIOD_START_COMMENT = 'Started trial period' -TRIAL_PERIOD_FINISH_COMMENT = 'Finished trial period' - +#remove +# TRIAL_PERIOD_IN_DAYS = 30 +# TRIAL_PERIOD_BALANCE = 100 +# TRIAL_PERIOD_START_COMMENT = 'Started trial period' +# TRIAL_PERIOD_FINISH_COMMENT = 'Finished trial period' +# SITE_ID = 1 +#remove DEFAULT_SYSTEM_NOTIFICATION_EMAIL = "" DEFAULT_TRANSACTION_ERROR = "Something went wrong, please contact us" diff --git a/webapplication/sip/urls.py b/webapplication/sip/urls.py index c7b0b874..1f3c4e92 100644 --- a/webapplication/sip/urls.py +++ b/webapplication/sip/urls.py @@ -33,7 +33,7 @@ api_patterns = [ path('', include("publisher.urls")), path('', include("aoi.urls")), - path('', include("user.urls")), + # path('', include("user_management.urls")), path('', include(auth_patterns)) ] api_patterns.extend(doc_urls) diff --git a/webapplication/user/admin.py b/webapplication/user/admin.py index f7661d03..c9e9c020 100644 --- a/webapplication/user/admin.py +++ b/webapplication/user/admin.py @@ -1,43 +1,20 @@ -from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.utils.translation import gettext_lazy as _ -from django.db import transaction -from user.models import User, Transaction - - -class UserForm(forms.ModelForm): - top_up_balance = forms.DecimalField(label=_('Top up Balance'), max_digits=9, decimal_places=2, required=False) - top_up_comment = forms.CharField(label=_('Top up Comment'), widget=forms.Textarea, required=False) - default_comment = 'Balance replenishment' - - class Meta: - model = User - fields = '__all__' - - def save(self, commit=True): - top_up_balance = self.cleaned_data.get('top_up_balance', None) - top_up_comment = self.cleaned_data.get('top_up_comment', self.default_comment) - if top_up_balance: - self.instance.top_up_balance(top_up_balance, top_up_comment) - return super().save(commit) +from user.models import User class UserAdmin(BaseUserAdmin): - form = UserForm list_display = ('username', 'email', 'is_staff', 'is_active', 'is_superuser', 'area_limit_ha', 'receive_news') list_filter = ('is_staff', 'is_superuser', 'is_active', 'groups', 'receive_news') fieldsets = ( ('Personal', {'fields': ('username', 'first_name', 'last_name', 'email', 'area_limit_ha', 'planet_api_key', 'receive_notification')}), - ('Billing', {'fields': ('balance', 'on_hold', 'discount')}), - ('Top up', {'fields': ('top_up_balance', 'top_up_comment')}), ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', )}), ('User permissions', {'fields': ('user_permissions', )}), ('Important dates', {'fields': ('last_login', 'date_joined')}), ('Email notification', {'fields': ('receive_news', )}), ) - readonly_fields = ('balance',) add_fieldsets = ( (None, { 'classes': ('wide',), @@ -45,35 +22,6 @@ class UserAdmin(BaseUserAdmin): }), ) - def get_readonly_fields(self, request, obj=None): - readonly_fields = super().get_readonly_fields(request, obj) - if obj and not request.user.has_perm("user.can_change_balance"): - return readonly_fields + ('top_up_balance', 'top_up_comment') - return readonly_fields - admin.site.register(User, UserAdmin) - -@admin.register(Transaction) -class TransactionModel(admin.ModelAdmin): - list_display = ('amount', 'user', 'request', 'created_at', 'completed') - list_filter = ('created_at', 'completed', 'rolled_back') - search_fields = ('user', 'request') - readonly_fields = ('amount', 'user', 'request', 'created_at', 'updated_at') - - fieldsets = ( - (_('Transaction info'), { - 'fields': ('amount', 'user', 'request', 'comment', 'error', 'completed', 'rolled_back') - }), - (_('Important dates'), { - 'classes': ('collapse',), - 'fields': (('created_at', 'updated_at',),) - }) - ) - raw_id_fields = ("user", "request") - add_fieldsets = ( - (_('Transaction info'), { - 'fields': ('amount', 'user', 'request', 'comment', 'error', 'completed') - }), - ) diff --git a/webapplication/user/fixtures/transaction_fixtures.json b/webapplication/user/fixtures/transaction_fixtures.json deleted file mode 100644 index b031c769..00000000 --- a/webapplication/user/fixtures/transaction_fixtures.json +++ /dev/null @@ -1,41 +0,0 @@ -[ - { - "model": "user.transaction", - "pk": 1001, - "fields": { - "user": 1001, - "request": 1001, - "amount": -20.42, - "created_at": "2023-02-15T11:14:31.140000Z", - "updated_at": "2023-02-15T11:14:31.140000Z", - "comment": "", - "completed": true - } - }, - { - "model": "user.transaction", - "pk": 1002, - "fields": { - "user": 1002, - "request": null, - "amount": 30, - "created_at": "2023-02-15T11:15:11.230000Z", - "updated_at": "2023-02-15T11:15:11.230000Z", - "comment": "", - "completed": false - } - }, - { - "model": "user.transaction", - "pk": 1003, - "fields": { - "user": 1003, - "request": 1001, - "amount": -8.14, - "created_at": "2023-02-15T11:16:21.210000Z", - "updated_at": "2023-02-15T11:16:21.210000Z", - "comment": "", - "completed": true - } - } -] \ No newline at end of file diff --git a/webapplication/user/management/commands/check_trial.py b/webapplication/user/management/commands/check_trial.py deleted file mode 100644 index c18a16a9..00000000 --- a/webapplication/user/management/commands/check_trial.py +++ /dev/null @@ -1,24 +0,0 @@ -import logging -from django.utils import timezone -from datetime import timedelta -from django.conf import settings -from django.core.management.base import BaseCommand -from user.models import User - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - help = "Check if the trial period has ended" - - def handle(self, *args, **options): - logger.info("starting to search users with expired trials") - thirty_days_ago = timezone.now() - timedelta(days=settings.TRIAL_PERIOD_IN_DAYS) - users_with_expired_trials = User.objects.filter( - trial_started_at__isnull=False, trial_started_at__lte=thirty_days_ago - ) - logger.info(f"found {len(users_with_expired_trials)} users with expired trials") - for user in users_with_expired_trials: - user.finish_trial() - logger.info(f"finished trial period for user: {user.username}") - logger.info("finished to search users with expired trials") diff --git a/webapplication/user/migrations/0024_delete_transaction.py b/webapplication/user/migrations/0024_delete_transaction.py new file mode 100644 index 00000000..70f58b99 --- /dev/null +++ b/webapplication/user/migrations/0024_delete_transaction.py @@ -0,0 +1,15 @@ +# Generated by Django 3.1 on 2023-10-23 13:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("user", "0023_user_receive_news"), + ] + + operations = [ + migrations.DeleteModel( + name="Transaction", + ), + ] diff --git a/webapplication/user/migrations/0025_auto_20231023_1435.py b/webapplication/user/migrations/0025_auto_20231023_1435.py new file mode 100644 index 00000000..fd798893 --- /dev/null +++ b/webapplication/user/migrations/0025_auto_20231023_1435.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2023-10-23 14:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0024_delete_transaction'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'verbose_name': 'user', 'verbose_name_plural': 'users'}, + ), + ] diff --git a/webapplication/user/models.py b/webapplication/user/models.py index ddb462a3..84f7458a 100644 --- a/webapplication/user/models.py +++ b/webapplication/user/models.py @@ -1,13 +1,10 @@ -from django.utils import timezone -from django.conf import settings from django.contrib.gis.db import models from django.contrib.auth.models import AbstractUser from django.contrib.gis.geos import GEOSGeometry -from django.db import transaction from django.utils.translation import gettext_lazy as _ from django.core.validators import MaxValueValidator -from aoi.models import AoI, Request +from aoi.models import AoI class User(AbstractUser): @@ -24,10 +21,6 @@ class User(AbstractUser): receive_news = models.BooleanField(default=False, verbose_name='Receive News') - class Meta: - permissions = ( - ("can_change_balance", "Can change balance"), - ) @property def areas_total_ha(self): @@ -65,60 +58,3 @@ def can_update_area(self, aoi_id, polygon_str): if self.areas_total_ha - old_area_ha + new_area_ha > self.area_limit_ha: return False return True - - @property - def actual_balance(self): - return self.balance - self.on_hold - - def finish_trial (self): - self.trial_finished_at=timezone.now() - self.top_up_balance(-self.balance, settings.TRIAL_PERIOD_FINISH_COMMENT) - self.save(update_fields=("trial_finished_at",)) - - def start_trial (self): - self.top_up_balance(settings.TRIAL_PERIOD_BALANCE, settings.TRIAL_PERIOD_START_COMMENT) - self.trial_started_at=timezone.now() - self.save(update_fields=("trial_started_at",)) - - def top_up_balance(self, amount, comment): - if self.trial_started_at and not self.trial_finished_at: - self.finish_trial() - - with transaction.atomic(): - Transaction.objects.create( - user=self, - amount=amount, - comment=comment, - completed=True - ) - self.balance += amount - self.save(update_fields=("balance",)) - - -class Transaction(models.Model): - user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="transactions") - amount = models.DecimalField(_("Amount"), max_digits=9, decimal_places=2) - created_at = models.DateTimeField(_("Created at"), auto_now_add=True, db_index=True) - updated_at = models.DateTimeField(_("Updated at"), auto_now=True) - request = models.ForeignKey(Request, on_delete=models.PROTECT, default=None, blank=True, null=True, - related_name="transactions") - comment = models.TextField(_("Comment"), blank=True, default="") - error = models.CharField(max_length=400, blank=True, null=True, verbose_name='Error') - completed = models.BooleanField(_("Completed"), default=False, blank=True, null=True) - rolled_back = models.BooleanField(_("Rolled back"), default=False, blank=True, null=True) - - class Meta: - ordering = ["-created_at"] - verbose_name = _("Transaction") - verbose_name_plural = _("Transactions") - permissions = ( - ("view_all_transactions", "Can view all transactions"), - ) - - @staticmethod - def generate_error(errors): - if errors: - return ', '.join([error for error in errors]) - else: - return settings.DEFAULT_TRANSACTION_ERROR - diff --git a/webapplication/user/serializers.py b/webapplication/user/serializers.py index 075a877b..2ec54895 100644 --- a/webapplication/user/serializers.py +++ b/webapplication/user/serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers from user.forms import PasswordResetForm -from user.models import User, Transaction +from user.models import User class UserSerializer(serializers.ModelSerializer): @@ -13,14 +13,6 @@ class Meta: read_only_fields = ('email', 'area_limit_ha', 'balance', 'on_hold', 'discount', 'trial_started_at', 'trial_finished_at') -class TransactionSerializer(serializers.ModelSerializer): - class Meta: - model = Transaction - fields = ('id', 'user', 'amount', 'created_at', 'updated_at', 'request', 'comment', 'error', 'completed', 'rolled_back') - read_only_fields = ('user', 'amount', 'created_at', 'updated_at', 'request', 'comment', 'error', 'completed', - 'rolled_back') - - class PasswordResetSerializer(DefaultPasswordResetSerializer): @property def password_reset_form_class(self): diff --git a/webapplication/user/tests.py b/webapplication/user/tests.py index 93f235e6..83c66f55 100644 --- a/webapplication/user/tests.py +++ b/webapplication/user/tests.py @@ -14,11 +14,9 @@ class UserBase(APITestCase): @staticmethod def add_users_special_permissions(): delete_any_result_permission = Permission.objects.get(codename='delete_any_result') - view_all_transactions_permission = Permission.objects.get(codename='view_all_transactions') staff_user = User.objects.get(id=1001) staff_user.user_permissions.add(delete_any_result_permission) - staff_user.user_permissions.add(view_all_transactions_permission) @staticmethod def add_users_to_groups(): @@ -278,86 +276,3 @@ def test_email_resend(self): response = self.client.post(url, input_data) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(mail.outbox), 2) - - -class TransactionTestCase(UserBase): - fixtures = ( - 'user/fixtures/user_fixtures.json', - 'aoi/fixtures/aoi_fixtures.json', - 'aoi/fixtures/notebook_fixtures.json', - 'aoi/fixtures/request_fixtures.json', - 'user/fixtures/transaction_fixtures.json' - ) - - def test_get_transactions_list_authorized(self): - response_data = [ - { - "id": 1002, - "user": 1002, - "amount": 30, - "created_at": "2023-02-15T11:15:11.230000Z", - "updated_at": "2023-02-15T11:15:11.230000Z", - "request": None, - "comment": "", - "error": None, - "completed": False, - "rolled_back": False - } - ] - url = reverse("get_transactions_list") - self.client.force_login(self.ex_2_user) - response = self.client.get(url) - self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(len(response.data), len(response_data)) - self.assertEqual(response.json(), response_data) - - def test_get_transactions_list_authorized_as_admin(self): - response_data = [ - { - "id": 1003, - "user": 1003, - "amount": -8.14, - "created_at": "2023-02-15T11:16:21.210000Z", - "updated_at": "2023-02-15T11:16:21.210000Z", - "request": 1001, - "comment": "", - "error": None, - "completed": True, - "rolled_back": False - }, - { - "id": 1002, - "user": 1002, - "amount": 30, - "created_at": "2023-02-15T11:15:11.230000Z", - "updated_at": "2023-02-15T11:15:11.230000Z", - "request": None, - "comment": "", - "error": None, - "completed": False, - "rolled_back": False - }, - { - "id": 1001, - "user": 1001, - "amount": -20.42, - "created_at": "2023-02-15T11:14:31.140000Z", - "updated_at": "2023-02-15T11:14:31.140000Z", - "request": 1001, - "comment": "", - "error": None, - "completed": True, - "rolled_back": False - } - ] - url = reverse("get_transactions_list") - self.client.force_login(self.staff_user) - response = self.client.get(url) - self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(len(response.data), len(response_data)) - self.assertEqual(response.json(), response_data) - - def test_get_transactions_list_not_authorized(self): - url = reverse("get_transactions_list") - response = self.client.get(url) - self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) diff --git a/webapplication/user/urls.py b/webapplication/user/urls.py deleted file mode 100644 index d175e953..00000000 --- a/webapplication/user/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path - -from user.views import TransactionListAPIView - -urlpatterns = [ - path('transactions/', TransactionListAPIView.as_view(), name='get_transactions_list'), -] diff --git a/webapplication/user/views.py b/webapplication/user/views.py index 58e23516..a9682f9b 100644 --- a/webapplication/user/views.py +++ b/webapplication/user/views.py @@ -1,21 +1,18 @@ from allauth.account.views import ConfirmEmailView from dj_rest_auth.registration.views import RegisterView as BasicRegisterView -from django.conf import settings from django.contrib.auth.models import Group from django.http import Http404 from django.utils.translation import gettext_lazy as _ -from rest_framework.generics import ListAPIView -from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.permissions import AllowAny from rest_framework.exceptions import NotFound from rest_framework.response import Response from rest_framework.status import HTTP_200_OK from rest_framework.views import APIView from dj_rest_auth.views import UserDetailsView +from django.apps import apps -from user.models import Transaction from aoi.models import Component from django.db.models import Q -from user.serializers import TransactionSerializer from waffle import switch_is_active @@ -25,7 +22,9 @@ def perform_create(self, serializer): user = super().perform_create(serializer) client_group = Group.objects.get(name="Client") user.groups.add(client_group) - user.start_trial() + if apps.is_installed("user_management"): + from user_management.models import UserTransaction + UserTransaction.start_trial(user) return user @@ -59,15 +58,3 @@ def get(self, *args, **kwargs): raise NotFound(_("Email verification failed")) return Response(data=_("Email has been successfully confirmed!"), status=HTTP_200_OK) - -class TransactionListAPIView(ListAPIView): - permission_classes = (IsAuthenticated, ) - queryset = Transaction.objects.all() - serializer_class = TransactionSerializer - pagination_class = None - - def get_queryset(self): - queryset = super().get_queryset() - if self.request.user.has_perm("user.view_all_transactions"): - return queryset - return queryset.filter(user=self.request.user) From 1a30c109b6ecfed9d239f507753c35c426ae63bd Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Tue, 7 Nov 2023 15:50:39 +0200 Subject: [PATCH 06/15] ID23614: updated view --- webapplication/aoi/views.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/webapplication/aoi/views.py b/webapplication/aoi/views.py index cd8214eb..2d79e7aa 100644 --- a/webapplication/aoi/views.py +++ b/webapplication/aoi/views.py @@ -16,6 +16,7 @@ from user.models import User from allauth.account import app_settings from allauth.utils import build_absolute_uri +from django.db import transaction from django.apps import apps @@ -204,15 +205,27 @@ def create(self, request, *args, **kwargs): return Response(as_serializer_error(validation_error), status=status.HTTP_400_BAD_REQUEST) if apps.is_installed("user_management"): - from user_management.utils import create_transaction_and_check_money_amount - result = create_transaction_and_check_money_amount(request, area, component, serializer) - if result.get("error", ""): - return Response(as_serializer_error(result['error']), status=status.HTTP_400_BAD_REQUEST) - if result.get("request_price", ""): + request_price = component.calculate_request_price( + user=request.user, + area=area + ) + if serializer.validated_data.get("pre_submit"): self.perform_create(serializer) - return Response({**serializer.data, "price": result['request_price']}, status=status.HTTP_200_OK) - - self.perform_create(serializer) + return Response({**serializer.data, "price": request_price}, status=status.HTTP_200_OK) + actual_balance = request.user.balance - request.user.on_hold + if actual_balance < request_price: + validation_error = ValidationError(_(f"Your actual balance is {actual_balance}. " + f"It’s not enough to run the request. Please replenish the balance. " + f"Contact support (support@soilmate.ai)")) + return Response(as_serializer_error(validation_error), status=status.HTTP_400_BAD_REQUEST) + from user_management.utils import create_transaction + with transaction.atomic(): + self.perform_create(serializer) + create_transaction( + user=request.user, + amount=request_price, + request=serializer.instance, + ) if not serializer.instance: validation_error = ValidationError(_("Error while creating a report")) return Response(as_serializer_error(validation_error), status=status.HTTP_400_BAD_REQUEST) From c6c41106ea491909ab7e932c6ea3ff58842ce683 Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Tue, 7 Nov 2023 18:06:52 +0200 Subject: [PATCH 07/15] ID23614: updated notebook, admin --- webapplication/aoi/admin.py | 1 - .../aoi/management/commands/_notebook.py | 41 ++----------------- 2 files changed, 3 insertions(+), 39 deletions(-) diff --git a/webapplication/aoi/admin.py b/webapplication/aoi/admin.py index ffbae3f0..0325a614 100644 --- a/webapplication/aoi/admin.py +++ b/webapplication/aoi/admin.py @@ -27,4 +27,3 @@ class RequestAdmin(admin.OSMGeoAdmin): list_display = ('pk', 'user', 'aoi', 'component', 'date_from', 'date_to', 'started_at', 'finished_at', 'calculated', 'success', 'error', 'additional_parameter', 'user_readable_errors') readonly_fields = ['pk', 'started_at', 'calculated', 'error', ] - diff --git a/webapplication/aoi/management/commands/_notebook.py b/webapplication/aoi/management/commands/_notebook.py index c03eb0b3..e86e06b5 100644 --- a/webapplication/aoi/management/commands/_notebook.py +++ b/webapplication/aoi/management/commands/_notebook.py @@ -32,44 +32,6 @@ from django.apps import apps - -def success_complete_transaction(success_request): - request_transaction = success_request.transactions.first() - if request_transaction: - request_transaction.completed = True - request_transaction.user.on_hold -= abs(request_transaction.amount) - request_transaction.user.balance -= abs(request_transaction.amount) - with transaction.atomic(): - request_transaction.save(update_fields=("completed",)) - request_transaction.user.save(update_fields=("balance", "on_hold")) - -def failed_request_transaction(failed_request): - from user_management.models import Transaction - request_transaction = failed_request.transactions.first() - request_transaction.user.on_hold -= abs(request_transaction.amount) - request_transaction.rolled_back = True - - request_transaction.completed = True - request_transaction.error = Transaction.generate_error(failed_request.user_readable_errors) - with transaction.atomic(): - request_transaction.save(update_fields=("rolled_back", "completed", "error")) - request_transaction.user.save(update_fields=("on_hold",)) - -def user_readable_error(request_with_error): - from user_management.models import TransactionErrorMessage - known_errors = [error.original_component_error for error in TransactionErrorMessage.objects.all()] - errors = [] - for error in known_errors: - if error in request_with_error.error: - errors.append(TransactionErrorMessage.objects.get(original_component_error=error).user_readable_error) - if errors: - request_with_error.user_readable_errors = errors - request_with_error.save(update_fields=['user_readable_errors']) - logger.info("Known error added") - else: - logger.info("No known error for component error") -######### - def clean_container_logs(logs): # Remove line numbers # like from this "00m ValueError("Images not loaded for given AOI." @@ -235,6 +197,7 @@ def execute_notebook(self): request.save(update_fields=['error']) if apps.is_installed("user_management"): + from user_management.utils import user_readable_error, failed_request_transaction user_readable_error(request) failed_request_transaction(request) @@ -267,6 +230,7 @@ def execute_notebook(self): request.save(update_fields=['finished_at']) if apps.is_installed("user_management"): + from user_management.utils import failed_request_transaction failed_request_transaction(request) email_notification(request, "failed") @@ -293,6 +257,7 @@ def publish_results(): for sr in success_requests: if apps.is_installed("user_management"): + from user_management.utils import success_complete_transaction success_complete_transaction(sr) email_notification(sr, "succeeded") From 4adb063b20249dbae110236fd275bcdede5a0223 Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Wed, 8 Nov 2023 12:13:59 +0200 Subject: [PATCH 08/15] ID23614: updated updated settings --- webapplication/sip/settings.py | 9 +-------- webapplication/sip/urls.py | 1 - 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/webapplication/sip/settings.py b/webapplication/sip/settings.py index 2c71936e..0d6fe666 100644 --- a/webapplication/sip/settings.py +++ b/webapplication/sip/settings.py @@ -60,7 +60,6 @@ 'user', 'publisher', 'aoi', - # 'user_management' ] REST_FRAMEWORK = { @@ -253,13 +252,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEFAULT_FROM_EMAIL = 'noreply@geoap.quantumobile.com' EMAIL_SUBJECT = 'Geoap Notification' -#remove -# TRIAL_PERIOD_IN_DAYS = 30 -# TRIAL_PERIOD_BALANCE = 100 -# TRIAL_PERIOD_START_COMMENT = 'Started trial period' -# TRIAL_PERIOD_FINISH_COMMENT = 'Finished trial period' -# SITE_ID = 1 -#remove + DEFAULT_SYSTEM_NOTIFICATION_EMAIL = "" DEFAULT_TRANSACTION_ERROR = "Something went wrong, please contact us" diff --git a/webapplication/sip/urls.py b/webapplication/sip/urls.py index 1f3c4e92..9b28e43f 100644 --- a/webapplication/sip/urls.py +++ b/webapplication/sip/urls.py @@ -33,7 +33,6 @@ api_patterns = [ path('', include("publisher.urls")), path('', include("aoi.urls")), - # path('', include("user_management.urls")), path('', include(auth_patterns)) ] api_patterns.extend(doc_urls) From aa038daeaf769d5286a4972abb00a33acfd0027f Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Wed, 8 Nov 2023 12:35:07 +0200 Subject: [PATCH 09/15] ID23337: updated settings --- webapplication/sip/settings.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/webapplication/sip/settings.py b/webapplication/sip/settings.py index 00ff5973..0c4c608f 100644 --- a/webapplication/sip/settings.py +++ b/webapplication/sip/settings.py @@ -248,6 +248,3 @@ NOTEBOOK_EXECUTOR_MAX_JOBS = int(os.getenv('NOTEBOOK_EXECUTOR_MAX_JOBS', 2)) NOTEBOOK_POD_DATA_VOLUME_MOUNT_PATH = os.getenv('NOTEBOOK_POD_DATA_VOLUME_MOUNT_PATH', '/home/jovyan/work') GPU_CORES_PER_NOTEBOOK = int(os.getenv('GPU_CORES_PER_NOTEBOOK', 1)) - - -DEFAULT_TRANSACTION_ERROR = "Something went wrong, please contact us" From 27985f422d76bdbc58cabc04f13aa8b74c3891d5 Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Wed, 8 Nov 2023 17:54:04 +0200 Subject: [PATCH 10/15] ID23337: perform create --- webapplication/aoi/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webapplication/aoi/views.py b/webapplication/aoi/views.py index 2d79e7aa..1300e07b 100644 --- a/webapplication/aoi/views.py +++ b/webapplication/aoi/views.py @@ -226,6 +226,8 @@ def create(self, request, *args, **kwargs): amount=request_price, request=serializer.instance, ) + else: + self.perform_create(serializer) if not serializer.instance: validation_error = ValidationError(_("Error while creating a report")) return Response(as_serializer_error(validation_error), status=status.HTTP_400_BAD_REQUEST) From e195886b501761c3799f18d7ebc461488e59211b Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Wed, 8 Nov 2023 18:42:16 +0200 Subject: [PATCH 11/15] ID23337: updated crontab --- webapplication/crontab | 1 - 1 file changed, 1 deletion(-) diff --git a/webapplication/crontab b/webapplication/crontab index c359f91b..8129c134 100644 --- a/webapplication/crontab +++ b/webapplication/crontab @@ -1,3 +1,2 @@ 0 0 * * * python -m manage clean_sattelite_cache -0 5 * * * python -m manage check_trial */5 * * * * python -m manage check_remote_server From 8227e681d864551e77a9debf9b0e09273bbd0f91 Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Thu, 9 Nov 2023 16:32:51 +0200 Subject: [PATCH 12/15] ID23337: New line --- webapplication/aoi/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/webapplication/aoi/admin.py b/webapplication/aoi/admin.py index 0325a614..ffbae3f0 100644 --- a/webapplication/aoi/admin.py +++ b/webapplication/aoi/admin.py @@ -27,3 +27,4 @@ class RequestAdmin(admin.OSMGeoAdmin): list_display = ('pk', 'user', 'aoi', 'component', 'date_from', 'date_to', 'started_at', 'finished_at', 'calculated', 'success', 'error', 'additional_parameter', 'user_readable_errors') readonly_fields = ['pk', 'started_at', 'calculated', 'error', ] + From 86f98bcd783ebcde46c5c8f6e9a7e874164a69cb Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Thu, 9 Nov 2023 16:55:19 +0200 Subject: [PATCH 13/15] ID23337: updated user_management.urls --- webapplication/sip/urls.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webapplication/sip/urls.py b/webapplication/sip/urls.py index 9b28e43f..2917a0e2 100644 --- a/webapplication/sip/urls.py +++ b/webapplication/sip/urls.py @@ -8,6 +8,7 @@ from django.contrib import admin from django.urls import path, include, re_path from django.views.decorators.csrf import csrf_exempt +from django.apps import apps from user.views import VerifyEmailView, RegisterView, CustomUserDetailsView from .docs_drf_yasg import urlpatterns as doc_urls @@ -33,8 +34,10 @@ api_patterns = [ path('', include("publisher.urls")), path('', include("aoi.urls")), - path('', include(auth_patterns)) + path('', include(auth_patterns)), ] +if apps.is_installed("user_management"): + api_patterns.extend([path('', include("user_management.urls"))]) api_patterns.extend(doc_urls) urlpatterns = [ From 53aadb2336d2634e2cb54cda71de568822308dd8 Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Fri, 10 Nov 2023 12:26:24 +0200 Subject: [PATCH 14/15] ID23337: updated create request --- webapplication/aoi/models.py | 6 ------ webapplication/aoi/views.py | 14 ++++++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/webapplication/aoi/models.py b/webapplication/aoi/models.py index 9beef523..fd29ad0e 100644 --- a/webapplication/aoi/models.py +++ b/webapplication/aoi/models.py @@ -101,12 +101,6 @@ class Meta: verbose_name_plural = 'Components' ordering = ['name'] - def calculate_request_price(self, area: Decimal, user) -> Decimal: - """ - Request price = Area (in sq.km, rounded up) * Product basic price * (1-User Personal discount). - Format XX.XX - """ - return round(area * self.basic_price * (1 - user.discount), 2) class Request(models.Model): diff --git a/webapplication/aoi/views.py b/webapplication/aoi/views.py index 1300e07b..79ce1f4c 100644 --- a/webapplication/aoi/views.py +++ b/webapplication/aoi/views.py @@ -205,20 +205,22 @@ def create(self, request, *args, **kwargs): return Response(as_serializer_error(validation_error), status=status.HTTP_400_BAD_REQUEST) if apps.is_installed("user_management"): - request_price = component.calculate_request_price( + from user_management.utils import create_transaction, calculate_request_price + request_price = calculate_request_price( user=request.user, - area=area + area=area, + basic_price=component.basic_price ) if serializer.validated_data.get("pre_submit"): self.perform_create(serializer) return Response({**serializer.data, "price": request_price}, status=status.HTTP_200_OK) - actual_balance = request.user.balance - request.user.on_hold - if actual_balance < request_price: - validation_error = ValidationError(_(f"Your actual balance is {actual_balance}. " + + from user_management.models import UserTransaction + if UserTransaction.actual_balance(request.user) < request_price: + validation_error = ValidationError(_(f"Your actual balance is {UserTransaction.actual_balance(request.user)}. " f"It’s not enough to run the request. Please replenish the balance. " f"Contact support (support@soilmate.ai)")) return Response(as_serializer_error(validation_error), status=status.HTTP_400_BAD_REQUEST) - from user_management.utils import create_transaction with transaction.atomic(): self.perform_create(serializer) create_transaction( From e9c1d03cb6fcee039a695e10fcec333eb753c445 Mon Sep 17 00:00:00 2001 From: Artem Sviridov Date: Fri, 10 Nov 2023 13:22:33 +0200 Subject: [PATCH 15/15] ID23337: updated create request --- webapplication/aoi/views.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/webapplication/aoi/views.py b/webapplication/aoi/views.py index 79ce1f4c..2e154921 100644 --- a/webapplication/aoi/views.py +++ b/webapplication/aoi/views.py @@ -205,22 +205,22 @@ def create(self, request, *args, **kwargs): return Response(as_serializer_error(validation_error), status=status.HTTP_400_BAD_REQUEST) if apps.is_installed("user_management"): - from user_management.utils import create_transaction, calculate_request_price + from user_management.utils import create_transaction, calculate_request_price, get_balance_validation_error + from user_management.models import UserTransaction + request_price = calculate_request_price( user=request.user, area=area, basic_price=component.basic_price ) + if serializer.validated_data.get("pre_submit"): self.perform_create(serializer) return Response({**serializer.data, "price": request_price}, status=status.HTTP_200_OK) - - from user_management.models import UserTransaction + if UserTransaction.actual_balance(request.user) < request_price: - validation_error = ValidationError(_(f"Your actual balance is {UserTransaction.actual_balance(request.user)}. " - f"It’s not enough to run the request. Please replenish the balance. " - f"Contact support (support@soilmate.ai)")) - return Response(as_serializer_error(validation_error), status=status.HTTP_400_BAD_REQUEST) + return Response(as_serializer_error(get_balance_validation_error(request.user)), status=status.HTTP_400_BAD_REQUEST) + with transaction.atomic(): self.perform_create(serializer) create_transaction(