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/_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..6943b08d 100644 --- a/webapplication/aoi/management/commands/_notebook.py +++ b/webapplication/aoi/management/commands/_notebook.py @@ -6,24 +6,34 @@ from threading import Thread, Lock, Event from django.db import transaction +from django.apps import apps + +from aoi.models import Component, Request, AoI + +from user.models import User -from aoi.models import Component, Request, AoI, TransactionErrorMessage -from user.models import User, Transaction 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 from django.conf import settings from django.contrib.postgres.fields import ArrayField +from django.apps import apps + logger = logging.getLogger(__name__) THREAD_SLEEP = 10 + +def email_notification(request, status): + if apps.is_installed("geoap_email_notifications"): + from geoap_email_notifications.utils import email_notification + email_notification(request, status) + + def clean_container_logs(logs): # Remove line numbers # like from this "00m ValueError("Images not loaded for given AOI." @@ -39,49 +49,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): @@ -186,28 +153,13 @@ 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"): + from user_management.utils import user_readable_error, failed_request_transaction + user_readable_error(request) + failed_request_transaction(request) + email_notification(request, "failed") try: container.remove() @@ -233,15 +185,13 @@ 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"): + from user_management.utils import failed_request_transaction + 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,25 +214,12 @@ 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"): + from user_management.utils import success_complete_transaction + success_complete_transaction(sr) + + email_notification(sr, "succeeded") 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 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..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): @@ -132,7 +126,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..2e154921 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,9 +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.db import transaction + +from django.apps import apps class AoIListCreateAPIView(ListCreateAPIView): @@ -180,14 +182,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 +204,32 @@ 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( + if apps.is_installed("user_management"): + 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, - amount=request_price, - request=serializer.instance, + 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) + + if UserTransaction.actual_balance(request.user) < request_price: + 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( + user=request.user, + 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) 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 diff --git a/webapplication/sip/settings.py b/webapplication/sip/settings.py index 37a939ae..00f5cfc5 100644 --- a/webapplication/sip/settings.py +++ b/webapplication/sip/settings.py @@ -59,7 +59,7 @@ # Local Apps 'user', 'publisher', - 'aoi' + 'aoi', ] REST_FRAMEWORK = { @@ -243,21 +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)) - -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" diff --git a/webapplication/sip/urls.py b/webapplication/sip/urls.py index c7b0b874..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,9 +34,10 @@ api_patterns = [ path('', include("publisher.urls")), path('', include("aoi.urls")), - path('', include("user.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 = [ 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 83550c50..00000000 --- a/webapplication/user/management/commands/check_trial.py +++ /dev/null @@ -1,26 +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, - trial_finished_at__isnull=True, - ) - 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)