diff --git a/.github/workflows/dev-deploy.yaml b/.github/workflows/dev-deploy.yaml new file mode 100644 index 00000000..08cddef9 --- /dev/null +++ b/.github/workflows/dev-deploy.yaml @@ -0,0 +1,33 @@ +name: Deploy to dev + +# Controls when the action will run. +on: + # Triggers the workflow on push request on the main branch for changes in the specified paths. + push: + branches: + - dev + paths: + - 'app/**' + - 'Dockerfile' + - 'docker-compose.dev.yml' + - '.github/workflows/**' + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: pulling latest dev commit and rebuilding app + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USER }} + key: ${{ secrets.DEV_KEY }} + command_timeout: 30m + script: | # docker volume rm app_pgdata + cd ~/app + git pull origin dev + docker compose -f docker-compose.dev.yml down + docker volume rm app_server app_beat app_celery + docker compose -f docker-compose.dev.yml up -d --build \ No newline at end of file diff --git a/.github/workflows/k8s-deploy.yaml b/.github/workflows/k8s-deploy.yaml new file mode 100644 index 00000000..9b8e5aff --- /dev/null +++ b/.github/workflows/k8s-deploy.yaml @@ -0,0 +1,79 @@ +# This workfow shows how to build a Docker image, tag and push it to Docker Hub Container Registry, and +# deploy the application on a DigitalOcean Kubernetes cluster. For description to the entire worklow, +# see www.digitalocean.com/docs/kubernetes/how-to/deploy-using-github-actions. + +name: Deploy to cursion-k8s + +# Controls when the action will run. +on: + # Triggers the workflow on push request on the main branch for changes in the specified paths. + push: + branches: + - main + paths: + - 'app/**' + - 'k8s/prod/**' + - 'Dockerfile' + - '.github/workflows/**' + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel. +jobs: + # This workflow contains a single job called "build". + build: + # The type of runner that the job will run on. + runs-on: ubuntu-latest + timeout-minutes: 30 + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it. + - name: Checkout main + uses: actions/checkout@main + + # Install doctl. + - name: Install doctl + uses: digitalocean/action-doctl@v2 + with: + token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} + + # Build a Docker image of your application in your registry and tag the image with the $GITHUB_SHA. + - name: Build container image + run: docker build -t ${{ secrets.REGISTRY_NAME }}/server:$(echo $GITHUB_SHA | head -c7) -t ${{ secrets.REGISTRY_NAME }}/server:latest . + + - name: Log in to Docker Hub Container Registry with short-lived credentialse + run: docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PASS }} + + - name: Push unique tag to Docker Hub Container Registry + run: docker image push ${{ secrets.REGISTRY_NAME }}/server:$(echo $GITHUB_SHA | head -c7) + + - name: Push latest tag to Docker Hub Container Registry + run: docker image push ${{ secrets.REGISTRY_NAME }}/server:latest + + # Update deployment files to reflect new build. + - name: Update app deployment file + run: TAG=$(echo $GITHUB_SHA | head -c7) && sed -i 's||${{ secrets.REGISTRY_NAME }}/server:'${TAG}'|' $GITHUB_WORKSPACE/k8s/prod/app-deployment.yaml + + - name: Update celery deployment file + run: TAG=$(echo $GITHUB_SHA | head -c7) && sed -i 's||${{ secrets.REGISTRY_NAME }}/server:'${TAG}'|' $GITHUB_WORKSPACE/k8s/prod/celery-deployment.yaml + + - name: Update beat deployment file + run: TAG=$(echo $GITHUB_SHA | head -c7) && sed -i 's||${{ secrets.REGISTRY_NAME }}/server:'${TAG}'|' $GITHUB_WORKSPACE/k8s/prod/beat-deployment.yaml + + - name: Save DigitalOcean kubeconfig with short-lived credentials + run: doctl kubernetes cluster kubeconfig save --expiry-seconds 600 ${{ secrets.CLUSTER_NAME }} + + # Re-deploy app, celery and beat + - name: Deploy app + run: kubectl apply -f $GITHUB_WORKSPACE/k8s/prod/app-deployment.yaml + - name: Deploy celery + run: kubectl apply -f $GITHUB_WORKSPACE/k8s/prod/celery-deployment.yaml + - name: Deploy beat + run: kubectl apply -f $GITHUB_WORKSPACE/k8s/prod/beat-deployment.yaml + + - name: Verify app + run: kubectl rollout status deployment/app-deployment + - name: Verify celery + run: kubectl rollout status deployment/celery-deployment + - name: Verify beat + run: kubectl rollout status deployment/beat-deployment diff --git a/.gitignore b/.gitignore index 780009eb..2fddb707 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,34 @@ -app/api/utils/testing_stuff.py -app/data* -app/api/utils/__pycache__/tester.cpython-38.pyc +db.sqlite3 .DS_Store + *__pycache__* -db.sqlite3 *.pyc __pycache__ __pycache__/ */__pycache__/* **/__pycache__/ -server/app/env* + env/.env.local env/.env.dev env/.env.prod +env/.env.stage +env/.env.remote env/.env.prod.db +env/.env.client.dev +env/.env.client.prod + + +app/data* app/static* -Dockerfile.alpine -Dockerfile.dev1 -Dockerfile.dev3 app/api/migrations/*_*.py + k8s/*/*-configs.yaml +k8s/prod/old_configs/* + +app/chromedriver* + +app/.cache* +app/.config* +app/.local* +app/.pki* +env/.env.client.dev diff --git a/Dockerfile b/Dockerfile index c1f55754..204d1157 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,47 +1,115 @@ -FROM python:3.9-slim -ENV PYTHONUNBUFFERED 1 +# pull main python image +FROM python:3.12-slim -# create the app user -RUN addgroup --system app && adduser --system app - -# installing python3 & pip -RUN apt-get update && apt-get install -y python3 python3-pip +# adding labels +LABEL Author="Cursion" Support="hello@cursion.dev" -# installing system deps -RUN apt-get update && apt-get install -y postgresql postgresql-client gcc \ - gfortran openssl libpq-dev curl libjpeg-dev chromium chromium-driver \ - libfontconfig +# setting ENVs and Configs +ENV HOME=/app +ENV XDG_CACHE_HOME=$HOME/.cache +ENV DOCKERIZED=yes +ENV DEBIAN_FRONTEND=noninteractive +ENV PYTHONUNBUFFERED=1 +ENV MOZ_NO_REMOTE=1 +ENV MOZ_DISABLE_AUTO_SAFE_MODE=1 +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PHANTOMAS_CHROMIUM_EXECUTABLE=/usr/bin/google-chrome-stable +ENV PYTHONPATH="$HOME:$PYTHONPATH" +ENV NODE_OPTIONS="--max-old-space-size=4080" +ENV DJANGO_ALLOWED_HOSTS="*" +ENV SECRET_KEY="abcdefghijklmno123456789" -# installing node and npm -RUN apt-get update && apt-get install nodejs npm -y --no-install-recommends \ - && npm install -g n && n lts +# create the app user +RUN addgroup --system app && adduser --system app -# increasing allocated memory to node -RUN export NODE_OPTIONS="--max-old-space-size=4096" +# installing system deps +RUN apt-get update && apt-get install -y --no-install-recommends \ + postgresql \ + postgresql-client \ + gcc \ + make \ + gfortran \ + openssl \ + libpq-dev \ + curl \ + libsm6 \ + libxrender1 \ + libxext6 \ + libgl1 \ + nasm \ + autoconf \ + libtool \ + automake \ + libjpeg-dev \ + libglib2.0-0 \ + libfreetype6 \ + ca-certificates \ + libfontconfig \ + apt-transport-https \ + software-properties-common + +# installing firefox-esr +RUN apt-get update && apt-get install -y --no-install-recommends firefox-esr -# installing lighthouse & yellowlabtools -RUN npm install -g lighthouse lighthouse-plugin-crux lodash yellowlabtools +# installing google-chrome-stable +RUN curl -LO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \ + apt-get install -y ./google-chrome-stable_current_amd64.deb && \ + rm google-chrome-stable_current_amd64.deb +# installing microsoft-edge-stable +RUN curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg && \ + install -o root -g root -m 644 microsoft.gpg /etc/apt/trusted.gpg.d/ && \ + sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > \ + /etc/apt/sources.list.d/microsoft-edge.list' && \ + apt-get update && apt-get install -y microsoft-edge-stable -# telling Puppeteer to skip installing Chrome -ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true +# installing node and npm +RUN curl -fsSL https://deb.nodesource.com/setup_current.x | bash - && \ + apt-get install -y --no-install-recommends nodejs && \ + npm install -g --no-cache n && \ + n lts -# telling phantomas where Chromium binary is and that we're in docker -ENV PHANTOMAS_CHROMIUM_EXECUTABLE /usr/bin/chromium -ENV DOCKERIZED yes +# installing lighthouse & lighthouse-plugin-crux +RUN npm install -g lighthouse lighthouse-plugin-crux -# setting --no-sandbox for Phantomas -RUN chromium --no-sandbox --version +# installing lodash & yellowlabtools +RUN npm install -g lodash yellowlabtools -# installing requirements -COPY ./requirements.txt /requirements.txt -RUN python3 -m pip install -r /requirements.txt +# copying & installing requirements +COPY ./setup/requirements/requirements.txt /requirements.txt +RUN python3.12 -m pip install -r /requirements.txt # setting working dir -RUN mkdir /app COPY ./app /app WORKDIR /app +# setting browser cache dirs +RUN mkdir -p .mozilla .cache + # setting ownership RUN chown -R app:app /app -RUN chown -R app:app /usr/bin/chromium \ No newline at end of file +RUN chown -R app:app /usr/local/bin/lighthouse +RUN chown -R app:app /usr/local/bin/yellowlabtools + +# writing migrations file +RUN python3.12 manage.py makemigrations --no-input + +# collecting static assets +RUN python3.12 manage.py collectstatic --no-input + +# cleaning up +RUN apt-get clean && rm -rf \ + /var/lib/apt/lists/* \ + /tmp/* \ + /var/tmp/* \ + microsoft.gpg + +# setting final user +USER app + +# staring up services +COPY ./setup/scripts/entrypoint.sh "/entrypoint.sh" +ENTRYPOINT [ "/entrypoint.sh" ] + + + diff --git a/Dockerfile.prod b/Dockerfile.prod deleted file mode 100644 index eb8edf96..00000000 --- a/Dockerfile.prod +++ /dev/null @@ -1,52 +0,0 @@ -FROM python:3.9-slim -ENV PYTHONUNBUFFERED 1 - -# create the app user -RUN addgroup --system app && adduser --system app - -# installing python3 & pip -RUN apt-get update && apt-get install -y python3 python3-pip - -# installing system deps -RUN apt-get update && apt-get install -y postgresql postgresql-client gcc \ - gfortran openssl libpq-dev curl libjpeg-dev chromium chromium-driver \ - libfontconfig git - -# installing node and npm -RUN apt-get update && apt-get install nodejs npm -y \ - && npm install -g n && n lts - -RUN npm cache clean --force - -# increasing allocated memory to node -# RUN export NODE_OPTIONS="--max-old-space-size=7000" -# RUN export NODE_OPTIONS="--stack-size=262000" -ENV NODE_OPTIONS=--max_old_space_size=7000 -ENV NODE_OPTIONS="--max-old-space-size=7000" -# ENV NODE_OPTIONS=--stack-size=262000 - -# installing lighthouse & yellowlabtools -RUN npm install -g lighthouse lighthouse-plugin-crux lodash yellowlabtools - -# telling Puppeteer to skip installing Chrome -ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true - -# telling phantomas where Chromium binary is and that we're in docker -ENV PHANTOMAS_CHROMIUM_EXECUTABLE /usr/bin/chromium -ENV DOCKERIZED yes - -# setting --no-sandbox for Phantomas -RUN chromium --no-sandbox --version - -# installing requirements -COPY ./requirements.txt /requirements.txt -RUN python3 -m pip install -r /requirements.txt - -# setting working dir -RUN mkdir /app -COPY ./app /app -WORKDIR /app - -# setting ownership -RUN chown -R app:app /app -RUN chown -R app:app /usr/bin/chromium \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md index ea474ece..4975f547 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,9 +1,11 @@ -Copyright (c) 2023 Scanerr +Copyright (c) 2024 Cursion -Scanerr Commercial Software License Terms +Cursion Commercial Software License Terms 1. Order. These terms, together with the order referencing them, make up a software license agreement. The software, the developer, and the customer are all identified on the order. +(i) Software: Cursion +(ii) Developer: Grey Labs, LLC (https://greylabs.io) 2. Versions. This agreement covers the specific version of the software on the order, plus any new versions of the software that the vendor makes generally available, or specifically provides to the customer, while this agreement continues. 3. Modifications. The customer may make changes to the software’s source code, compile those changes, and run changed versions of the software. 4. Billing. @@ -15,7 +17,7 @@ Scanerr Commercial Software License Terms 6. Use. (a) Permitted Use. The customer may use the software only for its own computing needs and those of its subsidiaries and corporate affiliates. (b) Prohibited Uses. The customer may not: -(i) sell, lease, license, or sublicense the software or documentation +(i) sell, lease, license, or sublicense the software or documentation (ii) allow access to the software by others not licensed under this agreement (iii) share copies of the software or documentation with with others not licensed under this agreement (iv) make so much of the functionality of the software available to others as software-as-a-service that the service competes with the software for customers diff --git a/README.md b/README.md index d7c28503..ae820eff 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,18 @@ -# Scanerr Server (API repo) +# Cursion Server (API repo) -[![Build Status](http://img.shields.io/travis/badges/badgerbadgerbadger.svg?style=flat-square)](https://api.scanerr.io) +![Build Status](https://github.com/cursion-dev/server/actions/workflows/k8s-deploy.yaml/badge.svg) -This is the server repo for the Scanerr API, an error detection service designed to run front-end tests on web-apps and sites. This service is fully dockertized for local dev/testing as well as deployed environments. +This is the server repo for the Cursion API, an error detection service designed to run front-end tests on web applications. This service is fully dockertized for local dev/testing as well as deployed environments. > This software is only intended for internal white-label use and is not licensed for redristibution. See LICENSE for more information. -Copyright © Scanerr 2023 +Copyright © Cursion 2024 ---   -## Table of Contents +## Guides +- [Server Deployment](notes/Deployment.md) +- [k8s Deployment](notes/Kubernetes.md)   - -#### Env's and deployment -- [Scanerr Server (API repo)](#scanerr-server-api-repo) - - [Table of Contents](#table-of-contents) - - [Env's and deployment](#envs-and-deployment) - - [Environment](#environment) - - [Local](#local) - - [Remote](#remote) - - [Scripts](#scripts) - - -  - ---- -  - -## Environment - -Prior to running app, configure all env's located in the /env directory. There are example .env files for both production and local environments marked `.env.dev.example` and `.env.prod.example`. Prior to running the app, be sure to update with your unique keys, domains, passwords, etc, and remove the `.example` extention from the files. **Never store actual .env's in a repo.** Things to change: -- high level django configs -- admin credentials -- email credentials -- database configs -- google API keys -- stripe keys -- OAuth keys -- twilio credentials -- slack credentials -- s3 remote storage credentials - -  - ---- -  - -## Local -Install and run locally on your machine in a dev environment. - -> Ensure you have Docker and Docker-desktop installed and running on your machine prior to this step. - -```shell -$ pip3 install virtualenv -$ virtualenv appenv -$ source appenv/bin/activate -$ mkdir app -$ git clone https://github.com/Scanerr-io/server.git -``` -*Spin-up the application* -```shell -$ docker compose up --build -``` -*Spin-down the application* -```shell -$ docker compose up down -``` - -  - ---- -  - -## Remote -Install and deploy remotely in a production environment. - -> Ensure you have Docker installed and running on your server prior to this step. - -*Server configurations for Ubuntu 20.04* -``` shell -$ ssh root@your_server_ip -# apt update -# apt upgrade -# adduser {user} -# usermod -aG sudo {user} -# ufw allow OpenSSH -# ufw enable -# su {user} -``` - -*Add user to docker group* -```shell -$ sudo usermod -aG docker {user} -$ newgrp docker -``` - -*Generate SSH keys for GitHub* -``` shell -$ ssh-keygen -t ed25519 -C "your_github_email@example.com" -``` -- press `Enter` 3 times -```shell -$ eval "$(ssh-agent -s)" -$ ssh-add ~/.ssh/id_ed25519 -$ cat ~/.ssh/id_ed25519.pub -``` -- copy key to clipboard and paste in GutHub - - -*Create a dir to clone the app into* -``` shell -$ cd ~ -$ mkdir app -$ cd app -$ git clone https://github.com/Scanerr-io/server.git -``` -*Spin-up the application* -```shell -$ docker compose -f docker-compose.prod.yml up -d --build -``` -*Spin-down the application* -```shell -$ docker compose -f docker-compose.prod.yml down -``` -*Spin-down the application and removes the volumes* -```shell -$ docker-compose -f docker-compose.prod.yml down -v -``` - - -  - ---- - -  - -## Scripts - -*ssh into container* -``` shell -$ docker exec -it /bin/sh -``` diff --git a/app/api/admin.py b/app/api/admin.py index 16e08a0b..a726d93d 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -1,81 +1,259 @@ from django.contrib import admin from .models import * from datetime import datetime +from .v1.ops.services import ( + create_scan, create_test, + delete_site, delete_page, + delete_scan, delete_test, + delete_case, delete_caserun, + crawl_site +) +from .tasks import ( + reset_account_usage, + update_scan_score +) -@admin.register(Site) -class SiteAdmin(admin.ModelAdmin): - list_display = ('site_url', 'user', 'time_created') - search_fields = ('site_url',) - - -@admin.register(Test) -class TestAdmin(admin.ModelAdmin): - list_display = ('id', 'site', 'time_created', 'time_completed', 'type') - search_fields = ('site',) -@admin.register(Scan) -class ScanAdmin(admin.ModelAdmin): - list_display = ('id', 'site', 'time_created', 'time_completed') - search_fields = ('site',) - actions = ['mark_as_completed',] - - def mark_as_completed(self, request, queryset): - queryset.update(time_completed=datetime.now()) @admin.register(Account) class AccountAdmin(admin.ModelAdmin): list_display = ('__str__', 'time_created', 'type') search_fields = ('__str__',) + actions = ['reset_usage',] + + def reset_usage(self, request, queryset): + for account in queryset: + reset_account_usage.delay( + account_id=account.id + ) + + @admin.register(Member) class MemberAdmin(admin.ModelAdmin): - list_display = ('user', 'account', 'time_created', 'type', 'status') + list_display = ('email', 'account', 'time_created', 'type', 'status') search_fields = ('user__username', 'account__name') + + @admin.register(Card) class CardAdmin(admin.ModelAdmin): list_display = ('__str__', 'brand', 'last_four') search_fields = ('last_four',) + + + +@admin.register(Site) +class SiteAdmin(admin.ModelAdmin): + list_display = ('site_url', 'account', 'time_created') + search_fields = ('site_url', 'account') + actions = ['scan_sites', 'test_sites', 'delete_sites', 'crawl_sites'] + + def crawl_sites(self, request, queryset): + for site in queryset: + crawl_site( + id=site.id, + user=site.account.user + ) + + def scan_sites(self, request, queryset): + for site in queryset: + create_scan( + site_id=site.id, + user_id=site.account.user.id + ) + + def test_sites(self, request, queryset): + for site in queryset: + create_test( + site_id=site.id, + user_id=site.account.user.id + ) + + def delete_sites(self, request, queryset): + for site in queryset: + delete_site( + id=site.id, + user=site.account.user + ) + + + + +@admin.register(Page) +class SiteAdmin(admin.ModelAdmin): + list_display = ('page_url', 'account', 'time_created') + search_fields = ('page_url', 'account') + actions = ['scan_pages', 'test_pages', 'delete_pages',] + + def scan_pages(self, request, queryset): + for page in queryset: + create_scan( + page_id=page.id, + user_id=page.account.user.id + ) + + def test_pages(self, request, queryset): + for page in queryset: + create_test( + page_id=page.id, + user_id=page.account.user.id + ) + + def delete_pages(self, request, queryset): + for page in queryset: + delete_page( + id=page.id, + user=page.user + ) + + + + +@admin.register(Test) +class TestAdmin(admin.ModelAdmin): + list_display = ('id', 'page', 'time_created', 'time_completed', 'type') + search_fields = ('page__page_url',) + actions = ['delete_tests',] + + def delete_tests(self, request, queryset): + for test in queryset: + delete_test( + id=test.id, + account=test.page.account + ) + + + +@admin.register(Scan) +class ScanAdmin(admin.ModelAdmin): + list_display = ('id', 'page', 'time_created', 'time_completed') + search_fields = ('page__page_url',) + actions = ['delete_scans', 'mark_as_completed', 'add_scan_score' ] + + def delete_scans(self, request, queryset): + for scan in queryset: + delete_scan( + id=scan.id, + account=scan.page.account + ) + + def add_scan_score(self, request, queryset): + for scan in queryset: + update_scan_score.delay( + scan_id=scan.id + ) + + def mark_as_completed(self, request, queryset): + queryset.update(time_completed=datetime.now()) + + + + @admin.register(Report) class ReportAdmin(admin.ModelAdmin): list_display = ('__str__', 'time_created', 'user') + + @admin.register(Log) class LogAdmin(admin.ModelAdmin): list_display = ('__str__', 'time_created', 'status', 'user') + + @admin.register(Schedule) class ScheduleAdmin(admin.ModelAdmin): - list_display = ('__str__', 'time_created', 'status', 'user') + list_display = ('__str__', 'time_last_run', 'status', 'user', 'time_created') + + -@admin.register(Automation) -class AutomationAdmin(admin.ModelAdmin): +@admin.register(Alert) +class AlertAdmin(admin.ModelAdmin): list_display = ('__str__', 'time_created', 'schedule', 'user') + + @admin.register(Process) class ProcessAdmin(admin.ModelAdmin): - list_display = ('__str__', 'time_created', 'time_completed', 'progress', 'successful') + list_display = ('__str__', 'time_created', 'time_completed', 'progress', 'success') + + @admin.register(Case) class CaseAdmin(admin.ModelAdmin): - list_display = ('__str__', 'user', 'time_created',) + list_display = ('title', 'user', 'site', 'time_created',) + search_fields = ('title', 'site__site_url') + actions = ['delete_cases',] + + def delete_cases(self, request, queryset): + for case in queryset: + delete_case( + id=case.id, + user=case.user + ) + + + +@admin.register(CaseRun) +class CaseRunAdmin(admin.ModelAdmin): + list_display = ('title', 'user', 'time_created', 'time_completed',) + search_fields = ('title', 'site__site_url') + + actions = ['delete_caseruns',] + + def delete_caseruns(self, request, queryset): + for caserun in queryset: + delete_caserun( + id=caserun.id, + user=caserun.user + ) + + + + +@admin.register(Issue) +class IssueAdmin(admin.ModelAdmin): + list_display = ('title', 'account', 'time_created', 'status',) + search_fields = ('title', 'affected') + + + + +@admin.register(Flow) +class FlowAdmin(admin.ModelAdmin): + list_display = ('title', 'account', 'time_created',) + search_fields = ('title',) + + + + + +@admin.register(FlowRun) +class FlowRunAdmin(admin.ModelAdmin): + list_display = ('title', 'account', 'site', 'time_created', 'time_completed', 'status') + search_fields = ('title', 'site__site_url',) + + + + +@admin.register(Secret) +class SecretAdmin(admin.ModelAdmin): + list_display = ('__str__', 'account', 'time_created',) -@admin.register(Testcase) -class TestcaseAdmin(admin.ModelAdmin): - list_display = ('__str__', 'user', 'time_created', 'time_completed',) @admin.register(Mask) @@ -88,4 +266,8 @@ def mark_as_inactive(self, request, queryset): queryset.update(active=False) def mark_as_active(self, request, queryset): - queryset.update(active=True) \ No newline at end of file + queryset.update(active=True) + + + + \ No newline at end of file diff --git a/app/api/apps.py b/app/api/apps.py index 36985c8a..275aba4d 100644 --- a/app/api/apps.py +++ b/app/api/apps.py @@ -2,4 +2,7 @@ class ApiConfig(AppConfig): - name = 'api' \ No newline at end of file + name = 'api' + + def ready(self): + import api.signals \ No newline at end of file diff --git a/app/api/management/commands/create_admin.py b/app/api/management/commands/create_admin.py index 77c68bd6..af9a1b3d 100644 --- a/app/api/management/commands/create_admin.py +++ b/app/api/management/commands/create_admin.py @@ -1,16 +1,23 @@ from django.core.management.base import BaseCommand from rest_framework.authtoken.models import Token from django.contrib.auth.models import User -from ...models import Account +from ...models import Account, Member, get_permissions_default from ...utils.verify import verify -import os +import os, secrets + + + + + +# creates a new Admin user if None exists class Command(BaseCommand): def handle(self, *args, **options): username = os.environ.get('ADMIN_USER') email = os.environ.get('ADMIN_EMAIL') password = os.environ.get('ADMIN_PASS') + mode = os.environ.get('MODE') if User.objects.filter(is_superuser=True).count() == 0: print('Creating Admin User for %s (%s)' % (username, email)) admin = User.objects.create_superuser(email=email, username=username, password=password) @@ -23,15 +30,52 @@ def handle(self, *args, **options): user = User.objects.get(username=username) if not Account.objects.filter(user=user).exists(): print('Funding account for %s' % (username)) - Account.objects.create( + + # default usage + usage = { + 'sites': 0, + 'schedules': 0, + 'scans': 0, + 'tests': 0, + 'caseruns': 0, + 'flowruns': 0, + 'sites_allowed': 1000, + 'pages_allowed': 10, + 'schedules_allowed': 50, + 'scans_allowed': 100000, + 'tests_allowed': 100000, + 'caseruns_allowed': 100000, + 'flowruns_allowed': 100000, + 'nodes_allowed': 50, + 'conditions_allowed': 25, + 'retention_days': 1000, + } + + code = secrets.token_urlsafe(16) + + account = Account.objects.create( + name='Admin', user=user, active=True, - type='enterprise', - max_sites=10000, + type='selfhost' if mode == 'selfhost' else 'admin', + usage=usage, + code=code, + ) + + # get permissonions or default + permissions = get_permissions_default() + + member = Member.objects.create( + user=user, + email=email, + status='active', + type='admin', + account=account, + permissions=permissions ) + else: print('Accounts can only be initialized if no Accounts exist') if not Token.objects.filter(user=user).exists(): - Token.objects.create(user=user) - # verify() \ No newline at end of file + Token.objects.create(user=user) \ No newline at end of file diff --git a/app/api/management/commands/create_tasks.py b/app/api/management/commands/create_tasks.py new file mode 100644 index 00000000..4eb52fd5 --- /dev/null +++ b/app/api/management/commands/create_tasks.py @@ -0,0 +1,58 @@ +from django.core.management.base import BaseCommand +from django_celery_beat.models import PeriodicTask, IntervalSchedule +from datetime import datetime + + + + + + +# creating default system tasks +class Command(BaseCommand): + + def handle(self, *args, **options): + + tasks = [ + { + 'every': 2, + 'period': IntervalSchedule.MINUTES, + 'name': 'Redeliver Failed Tasks', + 'task': 'api.tasks.redeliver_failed_tasks' + }, + { + 'every': 1, + 'period': IntervalSchedule.DAYS, + 'name': 'Data Retention Cleanup', + 'task': 'api.tasks.data_retention' + }, + { + 'every': 1, + 'period': IntervalSchedule.DAYS, + 'name': 'Reset Account Usage', + 'task': 'api.tasks.reset_account_usage' + }, + ] + + # loop through and create + # PeriodicTasks for each + for task in tasks: + + print(f'Setting up Task: {task.get('name')}') + + try: + # create the schedule + schedule, created = IntervalSchedule.objects.get_or_create( + every=task.get('every'), + period=task.get('period'), + ) + + # create the task + PeriodicTask.objects.create( + interval=schedule, + name=task.get('name'), + task=task.get('task') + ) + + except Exception as e: + print(e) + diff --git a/app/api/management/commands/driver_p_test.py b/app/api/management/commands/driver_p_test.py deleted file mode 100644 index c85d2705..00000000 --- a/app/api/management/commands/driver_p_test.py +++ /dev/null @@ -1,14 +0,0 @@ -from ...utils.driver_p import driver_test -from django.core.management.base import BaseCommand -import asyncio - -# testing puppeteer, pyppeteer, and chromium installation and configs - -class Command(BaseCommand): - - def handle(self, *args, **options): - asyncio.run(driver_test()) - - - - diff --git a/app/api/management/commands/terminate_worker.py b/app/api/management/commands/terminate_worker.py new file mode 100644 index 00000000..1cadfa8c --- /dev/null +++ b/app/api/management/commands/terminate_worker.py @@ -0,0 +1,74 @@ +from cursion import celery +from django.core.management.base import BaseCommand +import time, os + + + + + + +# init warm shutdown (prevent new task acceptance) +class Command(BaseCommand): + + def handle(self, *args, **options): + + # get worker / pod name + default_worker = 'cursion-celery' + if os.environ.get('THIS_POD_NAME'): + default_worker = str(os.environ.get('THIS_POD_NAME')) + + # get celery worker + this_worker = f"celery@{default_worker}" + + # sending initial SIGTERM to celery worker for warm-shutdown + celery.app.control.broadcast('shutdown', destination=[this_worker]) + + + + +# check if current tasks have completed +def wait_for_tasks_to_complete(): + + # get worker / pod name + default_worker = 'cursion-celery' + if os.environ.get('THIS_POD_NAME'): + default_worker = str(os.environ.get('THIS_POD_NAME')) + + # get celery worker + this_worker = f"celery@{default_worker}" + + def get_task_list(): + + # set default + tasks = 0 + + try: + # Inspect all nodes. + i = celery.app.control.inspect() + + # Tasks received, but are still waiting to be executed. + reserved = i.reserved()[this_worker] + print(f'Reserved tasks -> {str(reserved)}') + + # Active tasks + active = i.active()[this_worker] + print(f'Active tasks -> {str(reserved)}') + + # Sum all tasks + tasks = len(active) + len(reserved) + + except Exception as e: + print(e) + + # return tasks count + return int(tasks) + + # get length of active and reserved task lists + tasks = get_task_list() + + # waiting for tasks to complete + while tasks > 0: + time.sleep(10) + tasks = get_task_list() + + \ No newline at end of file diff --git a/app/api/management/commands/driver_s_test.py b/app/api/management/commands/test_driver.py similarity index 83% rename from app/api/management/commands/driver_s_test.py rename to app/api/management/commands/test_driver.py index 3ae6438a..1fe02b82 100644 --- a/app/api/management/commands/driver_s_test.py +++ b/app/api/management/commands/test_driver.py @@ -1,8 +1,12 @@ -from ...utils.driver_s import driver_test +from ...utils.driver import driver_test from django.core.management.base import BaseCommand -# testing selenium, chromedriver, and chromium installation and configs + + + + +# testing selenium, chromedriver, and chromium installation and configs class Command(BaseCommand): def handle(self, *args, **options): diff --git a/app/api/management/commands/verify_account.py b/app/api/management/commands/verify_account.py new file mode 100644 index 00000000..a93de01b --- /dev/null +++ b/app/api/management/commands/verify_account.py @@ -0,0 +1,13 @@ +from django.core.management.base import BaseCommand +from ...utils.verify import verify + + + + + + +# verifies deployment +class Command(BaseCommand): + + def handle(self, *args, **options): + verify() \ No newline at end of file diff --git a/app/api/management/commands/wait_for_db.py b/app/api/management/commands/wait_for_db.py index c5edb897..def8f534 100644 --- a/app/api/management/commands/wait_for_db.py +++ b/app/api/management/commands/wait_for_db.py @@ -3,8 +3,13 @@ from django.db.utils import OperationalError from django.core.management import BaseCommand + + + + + +# Django command to pause execution until db is available class Command(BaseCommand): - """Django command to pause execution until db is available""" def handle(self, *args, **options): self.stdout.write('Waiting for database...') diff --git a/app/api/migrations/__init__.py b/app/api/migrations/__init__.py old mode 100644 new mode 100755 diff --git a/app/api/models.py b/app/api/models.py index 72e5b54d..cd7474ef 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -1,11 +1,15 @@ from django.db import models -from django.db import models from django.utils import timezone from django.urls import reverse from django.contrib.auth.models import User -from datetime import datetime +from datetime import datetime, timezone as tz from django.contrib.postgres.fields import JSONField -import uuid +from cursion import settings +import uuid, secrets + + + + def get_info_default(): @@ -14,17 +18,18 @@ def get_info_default(): 'id': None, 'time_created': None, 'time_completed': None, + 'score': None, }, 'latest_test': { 'id': None, 'time_created': None, 'time_completed': None, - 'score': None + 'score': None, + 'status': None }, 'lighthouse': { 'average': None, 'seo': None, - 'pwa': None, 'crux': None, 'performance': None, 'accessibility': None, @@ -33,7 +38,7 @@ def get_info_default(): 'yellowlab': { 'globalScore': None, 'pageWeight': None, - 'requests': None, + 'images': None, 'domComplexity': None, 'javascriptComplexity': None, 'badJavascript': None, @@ -42,17 +47,34 @@ def get_info_default(): 'badCSS': None, 'fonts': None, 'serverConfig': None, - }, - 'status': { - 'health': None, - 'badge': 'neutral', + } + } + return info_default + + + + +def get_small_info_default(): + info_default = { + 'latest_scan': { + 'id': None, + 'time_created': None, + 'time_completed': None, 'score': None, }, + 'latest_test': { + 'id': None, + 'time_created': None, + 'time_completed': None, + 'score': None, + 'status': None + } } return info_default + def get_lh_delta_default(): lh_delta_default = { "scores": { @@ -60,22 +82,23 @@ def get_lh_delta_default(): "performance_delta": None, "accessibility_delta": None, "best-practices_delta": None, - "pwa_delta": None, "crux_delta": None, "average_delta" : None, "current_average": None, }, + "audits": None } return lh_delta_default + def get_yl_delta_default(): yl_delta_default = { "scores": { "average_delta": None, "pageWeight_delta": None, - "requests_delta": None, + "images_delta": None, "domComplexity_delta": None, "javascriptComplexity_delta": None, "badJavascript_delta": None, @@ -85,11 +108,13 @@ def get_yl_delta_default(): "fonts_delta": None, "serverConfig_delta": None, }, + "audits": None } return yl_delta_default + def get_lh_default(): lh_default = { "scores": { @@ -97,29 +122,22 @@ def get_lh_default(): "performance": None, "accessibility": None, "best_practices": None, - "pwa": None, "crux": None, "average": None }, - "audits": { - "seo": [], - "performance": [], - "accessibility": [], - "best-practices": [], - "pwa": [], - "crux": [] - }, + "audits": None, } return lh_default + def get_yl_default(): yl_default = { "scores": { "globalScore": None, "pageWeight": None, - "requests": None, + "images": None, "domComplexity": None, "javascriptComplexity": None, "badJavascript": None, @@ -129,23 +147,13 @@ def get_yl_default(): "fonts": None, "serverConfig": None, }, - "audits": { - "pageWeight": [], - "requests": [], - "domComplexity": [], - "javascriptComplexity": [], - "badJavascript": [], - "jQuery": [], - "cssComplexity": [], - "badCSS": [], - "fonts": [], - "serverConfig": [], - }, + "audits": None, } return yl_default + def get_expressions_default(): expressions_default = { 'list': [ @@ -161,6 +169,7 @@ def get_expressions_default(): + def get_actions_default(): actions_default = { 'list': [ @@ -199,6 +208,7 @@ def get_steps_default(): + def get_scores_default(): scores_default = { 'html': None, @@ -211,6 +221,7 @@ def get_scores_default(): + def get_slack_default(): slack_default = { "slack_name": None, @@ -223,6 +234,8 @@ def get_slack_default(): return slack_default + + def get_tags_default(): tags_default = None, return tags_default @@ -230,20 +243,154 @@ def get_tags_default(): +def get_default_configs(): + configs = settings.CONFIGS + return configs + + + + +def get_usage_default(): + usage = { + 'sites': 0, + 'schedules': 0, + 'scans': 0, + 'tests': 0, + 'caseruns': 0, + 'flowruns': 0, + 'sites_allowed': 1, + 'pages_allowed': 3, + 'schedules_allowed': 1, + 'scans_allowed': 30, + 'tests_allowed': 30, + 'caseruns_allowed': 15, + 'flowruns_allowed': 5, + 'nodes_allowed': 4, + 'conditions_allowed': 1, + 'retention_days': 15, + } + return usage + + + + +def get_meta_default(): + meta = { + 'last_usage_reset': datetime.now(tz.utc).strftime('%Y-%m-%d %H:%M:%S.%f'), + 'coupon': { + 'code': '', + 'discount': 0 + } + } + return meta + + + + +def get_account_info_default(): + info = {'survey': []} + return info + + + + +def get_permissions_default(): + permissions = { + 'actions': [ + 'add', 'get', 'update', 'delete' + ], + 'resources': [ + 'site', 'page', 'issue', 'case', 'caserun', + 'flow', 'flowrun', 'test', 'scan', 'schedule', + 'alert', 'secret', 'report', 'process', 'log' + ], + 'sites': [] + } + return permissions + + + + +def get_system_default(): + system = { + 'tasks': [], + } + return system + + + + +def get_nodes_default(): + nodes = [ + { + 'id': '1', + 'position': { + 'x': 0, + 'y': 0 + }, + 'type': 'basic', + 'parentId': None, + 'data': { + 'id': '1', # duplicate for client support + 'position': { # duplicate for client support + 'x': 0, + 'y': 0 + }, + 'parentId': None, # duplicate for client support + 'task_type': None, + 'configs': settings.CONFIGS, + 'conditions': None, + 'start_if': None, + } + }, + ] + return nodes + + + + +def get_edges_default(): + edges = [] + return edges + + + + +def get_license_key(): + license_key = 'cursion-license-' + secrets.token_hex(32) + return license_key + + + + + + class Account(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.CharField(max_length=1000, serialize=True, null=True, blank=True) user = models.ForeignKey(User, on_delete=models.CASCADE, serialize=True) + # phone = models.CharField(max_length=50, serialize=True, null=True, blank=True) ## -> REMOVING !!!! active = models.BooleanField(default=False, serialize=True) time_created = models.DateTimeField(default=timezone.now, serialize=True) type = models.CharField(max_length=1000, serialize=True, null=True, blank=True, default='free') code = models.CharField(max_length=1000, serialize=True, null=True, blank=True) - max_sites = models.IntegerField(serialize=True, null=True, blank=True, default=1) + license_key = models.CharField(max_length=100, serialize=True, null=True, blank=True, default=get_license_key) ## -> NEW!!!!! + # max_sites = models.IntegerField(serialize=True, null=True, blank=True, default=1) ## -> REMOVING!!!! + # max_pages = models.IntegerField(serialize=True, null=True, blank=True, default=3) ## -> REMOVING!!!! + # max_schedules = models.IntegerField(serialize=True, null=True, blank=True, default=1) ## -> REMOVING!!!! + # retention_days = models.IntegerField(serialize=True, null=True, blank=True, default=3) ## -> REMOVING!!!! cust_id = models.CharField(max_length=1000, serialize=True, null=True, blank=True) sub_id = models.CharField(max_length=1000, serialize=True, null=True, blank=True) product_id = models.CharField(max_length=1000, serialize=True, null=True, blank=True) price_id = models.CharField(max_length=1000, serialize=True, null=True, blank=True) + price_amount = models.IntegerField(serialize=True, null=True, blank=True, default=0) + interval = models.CharField(max_length=50, serialize=True, null=True, blank=True, default='month') + usage = models.JSONField(serialize=True, null=True, blank=True, default=get_usage_default) slack = models.JSONField(serialize=True, null=True, blank=True, default=get_slack_default) + configs = models.JSONField(serialize=True, null=True, blank=True, default=get_default_configs) + info = models.JSONField(serialize=True, null=True, blank=True, default=get_account_info_default) ## -> NEW!!!!! + meta = models.JSONField(serialize=True, null=True, blank=True, default=get_meta_default) + def __str__(self): return self.user.email @@ -272,8 +419,10 @@ class Member(models.Model): account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True) user = models.ForeignKey(User, on_delete=models.CASCADE, serialize=True, null=True, blank=True) email = models.CharField(max_length=1000, serialize=True, null=True, blank=True) # created by Account admin + phone = models.CharField(max_length=50, serialize=True, null=True, blank=True) ## NEW !!! status = models.CharField(max_length=1000, serialize=True, null=True, blank=True) # pending, active type = models.CharField(max_length=1000, serialize=True, null=True, blank=True) # admin, contributor, client + permissions = models.JSONField(serialize=True, null=True, blank=True, default=get_permissions_default) ## NEW !!!!!!!! time_created = models.DateTimeField(default=timezone.now, serialize=True) def __str__(self): @@ -282,14 +431,29 @@ def __str__(self): +class Secret(models.Model): ### NEW !!!! + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True) + time_created = models.DateTimeField(default=timezone.now, serialize=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, serialize=True, null=True, blank=True) + name = models.CharField(max_length=500, serialize=True, null=True, blank=True) + value = models.TextField(serialize=True, null=True, blank=True) + + def __str__(self): + return f'{self.name}' + + + class Site(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) site_url = models.CharField(max_length=1000, serialize=True, null=True, blank=True) time_created = models.DateTimeField(default=timezone.now, serialize=True) + time_crawl_started = models.DateTimeField(serialize=True, null=True, blank=True) + time_crawl_completed = models.DateTimeField(serialize=True, null=True, blank=True) user = models.ForeignKey(User, on_delete=models.SET_NULL, serialize=True, null=True, blank=True) account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True, null=True, blank=True) - info = models.JSONField(serialize=True, null=True, blank=True, default=get_info_default) + info = models.JSONField(serialize=True, null=True, blank=True, default=get_small_info_default) tags = models.JSONField(serialize=True, null=True, blank=True, default=get_tags_default) def __str__(self): @@ -297,37 +461,61 @@ def __str__(self): + +class Page(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + site = models.ForeignKey(Site, on_delete=models.CASCADE, serialize=True, blank=True) + page_url = models.CharField(max_length=1000, serialize=True, null=True, blank=True) + time_created = models.DateTimeField(default=timezone.now, serialize=True) + user = models.ForeignKey(User, on_delete=models.SET_NULL, serialize=True, null=True, blank=True) + account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True, null=True, blank=True) + info = models.JSONField(serialize=True, null=True, blank=True, default=get_info_default) + tags = models.JSONField(serialize=True, null=True, blank=True, default=get_tags_default) + + def __str__(self): + return f'{self.page_url}' + + + + class Scan(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) site = models.ForeignKey(Site, on_delete=models.CASCADE, serialize=True, blank=True) + page = models.ForeignKey(Page, on_delete=models.CASCADE, serialize=True, blank=True) paired_scan = models.ForeignKey('self', on_delete=models.SET_NULL, serialize=True, null=True, blank=True) type = models.JSONField(serialize=True, null=True, blank=True) time_created = models.DateTimeField(default=timezone.now, serialize=True) time_completed = models.DateTimeField(serialize=True, null=True, blank=True) - html = models.TextField(serialize=True, null=True, blank=True) + html = models.CharField(max_length=5000, serialize=True, null=True, blank=True) logs = models.JSONField(serialize=True, null=True, blank=True) images = models.JSONField(serialize=True, null=True, blank=True) + score = models.FloatField(serialize=True, null=True, blank=True) lighthouse = models.JSONField(serialize=True, null=True, blank=True, default=get_lh_default) yellowlab = models.JSONField(serialize=True, null=True, blank=True, default=get_yl_default) configs = models.JSONField(serialize=True, null=True, blank=True) tags = models.JSONField(serialize=True, null=True, blank=True, default=get_tags_default) + system = models.JSONField(serialize=True, null=True, blank=True, default=get_system_default) def __str__(self): return f'{self.id}__scan' + class Test(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) site = models.ForeignKey(Site, on_delete=models.CASCADE, serialize=True) + page = models.ForeignKey(Page, on_delete=models.CASCADE, serialize=True, blank=True) time_created = models.DateTimeField(default=timezone.now, serialize=True) time_completed = models.DateTimeField(serialize=True, null=True, blank=True) type = models.JSONField(serialize=True, null=True, blank=True) pre_scan = models.ForeignKey(Scan, on_delete=models.SET_NULL, serialize=True, null=True, blank=True, related_name='pre_scan') post_scan = models.ForeignKey(Scan, on_delete=models.SET_NULL, serialize=True, null=True, blank=True, related_name='post_scan') score = models.FloatField(serialize=True, null=True, blank=True) + threshold = models.FloatField(serialize=True, null=True, blank=True) + status = models.CharField(max_length=500, serialize=True, null=True, blank=True) component_scores = models.JSONField(serialize=True, null=True, blank=True, default=get_scores_default) - html_delta = models.JSONField(serialize=True, null=True, blank=True) + html_delta = models.CharField(max_length=5000, serialize=True, null=True, blank=True) logs_delta = models.JSONField(serialize=True, null=True, blank=True) lighthouse_delta = models.JSONField(serialize=True, null=True, blank=True, default=get_lh_delta_default) yellowlab_delta = models.JSONField(serialize=True, null=True, blank=True, default=get_yl_delta_default) @@ -337,48 +525,45 @@ class Test(models.Model): post_scan_configs = models.JSONField(serialize=True, null=True, blank=True) def __str__(self): - return f'{self.id}__test' + return f'{self.id}_test' -class Schedule(models.Model): +class Case(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - site = models.ForeignKey(Site, on_delete=models.CASCADE, null=True, blank=True, serialize=True) - automation = models.ForeignKey('Automation', on_delete=models.SET_NULL, null=True, blank=True, serialize=True, related_name='assoc_auto') - time_created = models.DateTimeField(default=datetime.now, null=True, blank=True, serialize=True) + title = models.CharField(max_length=1000, serialize=True, null=True, blank=True) ## RENAMED !!!! from name to title user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, serialize=True) account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True, null=True, blank=True) - task_type = models.CharField(max_length=100, default='test', serialize=True) # report, scan, test, testcase - timezone = models.CharField(max_length=100, null=True, blank=True, serialize=True) - begin_date = models.DateTimeField(default=datetime.now, serialize=True) - time = models.CharField(max_length=100, null=True, blank=True, serialize=True) - frequency = models.CharField(default="monthly", max_length=100, serialize=True) # daily, weekly, monthly, - task = models.CharField(max_length=500, null=True, blank=True, serialize=True) # assigning shared task - crontab_id = models.CharField(max_length=500, null=True, blank=True, serialize=True) - periodic_task_id = models.CharField(max_length=500, null=True, blank=True, serialize=True) - status = models.CharField(max_length=100, default='Active', null=True, blank=True, serialize=True) - extras = models.JSONField(serialize=True, null=True, blank=True) + site = models.ForeignKey(Site, on_delete=models.CASCADE, null=True, blank=True, serialize=True) + site_url = models.CharField(max_length=1000, serialize=True, null=True, blank=True) + time_created = models.DateTimeField(default=timezone.now, serialize=True) + steps = models.JSONField(serialize=True, null=True, blank=True, default=get_steps_default) + type = models.CharField(max_length=1000, serialize=True, null=True, blank=True) + processed = models.BooleanField(default=False, serialize=True) + tags = models.JSONField(serialize=True, null=True, blank=True, default=get_tags_default) def __str__(self): - return f'{self.site.site_url}__{self.task_type}' + return f'{self.title}' if self.title else str(id) ### REMANED from self.name to self.title -class Automation(models.Model): +class CaseRun(models.Model): ## -> RENAME from Testcase to CaseRun !!!!!!! id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=1000, serialize=True, null=True, blank=True) - user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, serialize=True) + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, serialize=True) account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True, null=True, blank=True) + case = models.ForeignKey(Case, on_delete=models.CASCADE, null=True, blank=True, serialize=True) + title = models.CharField(max_length=500, null=True, blank=True, serialize=True) ## RENAMED !!!! from case_name to title + site = models.ForeignKey(Site, on_delete=models.CASCADE, null=True, blank=True, serialize=True) time_created = models.DateTimeField(default=timezone.now, serialize=True) - schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE, null=True, blank=True, serialize=True, related_name='assoc_sch') - expressions = models.JSONField(serialize=True, null=True, blank=True, default=get_expressions_default) - actions = models.JSONField(serialize=True, null=True, blank=True, default=get_actions_default) + time_completed = models.DateTimeField(null=True, blank=True, serialize=True) + status = models.CharField(max_length=20, default='working', null=True, blank=True, serialize=True) + steps = models.JSONField(serialize=True, null=True, blank=True) + configs = models.JSONField(serialize=True, null=True, blank=True) def __str__(self): - return f'{self.name}' - + return f'{self.title}_caserun' ## RENAMED !!! from case.name to title @@ -386,53 +571,115 @@ def __str__(self): class Report(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) site = models.ForeignKey(Site, on_delete=models.CASCADE, null=True, blank=True, serialize=True) + page = models.ForeignKey(Page, on_delete=models.CASCADE, null=True, blank=True, serialize=True) user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, serialize=True) account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True, null=True, blank=True) time_created = models.DateTimeField(default=timezone.now, serialize=True) path = models.CharField(max_length=1000, serialize=True, null=True, blank=True) - type = models.JSONField(serialize=True, null=True, blank=True) # array of [lighthouse, yellowlab] + type = models.JSONField(serialize=True, null=True, blank=True) info = models.JSONField(serialize=True, null=True, blank=True) def __str__(self): - return f'{self.site.site_url}__report' + return f'{self.page.page_url}_report' - -class Case(models.Model): +class Issue(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=1000, serialize=True, null=True, blank=True) - user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, serialize=True) + time_created = models.DateTimeField(default=timezone.now, serialize=True) + trigger = models.JSONField(serialize=True, null=True, blank=True) account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True, null=True, blank=True) + title = models.CharField(max_length=1000, serialize=True, null=True, blank=True) + details = models.TextField(serialize=True, null=True, blank=True) + status = models.CharField(max_length=500, serialize=True, default='open') + affected = models.JSONField(serialize=True, null=True, blank=True) + labels = models.JSONField(serialize=True, null=True, blank=True) + read = models.BooleanField(default=False, serialize=True) + + def __str__(self): + return f'{self.title if self.title is not None else self.id}_issue' + + + + +class Flow(models.Model): ## -> NEW !!!!!!! + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) time_created = models.DateTimeField(default=timezone.now, serialize=True) - steps = models.JSONField(serialize=True, null=True, blank=True, default=get_steps_default) - tags = models.JSONField(serialize=True, null=True, blank=True, default=get_tags_default) + time_last_run = models.DateTimeField(serialize=True, null=True, blank=True) + account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True, null=True, blank=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, serialize=True, null=True, blank=True) + title = models.CharField(max_length=1000, serialize=True, null=True, blank=True) + nodes = models.JSONField(serialize=True, null=True, blank=True, default=get_nodes_default) + edges = models.JSONField(serialize=True, null=True, blank=True, default=get_edges_default) def __str__(self): - return f'{self.name}' + return f'{self.title if self.title is not None else self.id}_flow' -class Testcase(models.Model): +class FlowRun(models.Model): ## -> NEW !!!!!!! id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, serialize=True) + time_created = models.DateTimeField(default=timezone.now, serialize=True) + time_completed = models.DateTimeField(serialize=True, null=True, blank=True) account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True, null=True, blank=True) - case = models.ForeignKey(Case, on_delete=models.CASCADE, null=True, blank=True, serialize=True) - case_name = models.CharField(max_length=1000, null=True, blank=True, serialize=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, serialize=True, null=True, blank=True) + flow = models.ForeignKey(Flow, on_delete=models.CASCADE, null=True, blank=True, serialize=True) site = models.ForeignKey(Site, on_delete=models.CASCADE, null=True, blank=True, serialize=True) - time_created = models.DateTimeField(default=timezone.now, serialize=True) - time_completed = models.DateTimeField(null=True, blank=True, serialize=True) - passed = models.BooleanField(default=False, serialize=True) - steps = models.JSONField(serialize=True, null=True, blank=True) + title = models.CharField(max_length=1000, serialize=True, null=True, blank=True) + status = models.CharField(max_length=500, serialize=True, default='working') + nodes = models.JSONField(serialize=True, null=True, blank=True) + edges = models.JSONField(serialize=True, null=True, blank=True) + logs = models.JSONField(serialize=True, null=True, blank=True) configs = models.JSONField(serialize=True, null=True, blank=True) def __str__(self): - return f'{self.case.name}__testcase' + return f'{self.flow.title if self.flow.title is not None else self.id}_flowrun' - + + +class Schedule(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, serialize=True) + account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True, null=True, blank=True) + scope = models.CharField(max_length=100, default='account', serialize=True) + resources = models.JSONField(serialize=True, null=True, blank=True) + alert = models.ForeignKey('Alert', on_delete=models.SET_NULL, null=True, blank=True, serialize=True, related_name='assoc_alert') ## NEW !!!! + # automation = models.ForeignKey('Automation', on_delete=models.SET_NULL, null=True, blank=True, serialize=True, related_name='assoc_auto') ## REMOVE !!!! + time_created = models.DateTimeField(default=datetime.now, null=True, blank=True, serialize=True) + time_last_run = models.DateTimeField(null=True, blank=True, serialize=True) + task_type = models.CharField(max_length=100, default='test', serialize=True) + timezone = models.CharField(max_length=100, null=True, blank=True, serialize=True) + begin_date = models.DateTimeField(default=datetime.now, serialize=True) + time = models.CharField(max_length=100, null=True, blank=True, serialize=True) + frequency = models.CharField(default="monthly", max_length=100, serialize=True) + task = models.CharField(max_length=500, null=True, blank=True, serialize=True) + crontab_id = models.CharField(max_length=500, null=True, blank=True, serialize=True) + periodic_task_id = models.CharField(max_length=500, null=True, blank=True, serialize=True) + status = models.CharField(max_length=100, default='Active', null=True, blank=True, serialize=True) + extras = models.JSONField(serialize=True, null=True, blank=True) + + def __str__(self): + return f'{self.account.name}_{self.task_type}' + + + + +class Alert(models.Model): ## -> RENAME from Automation to alert !!!!!!! + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=1000, serialize=True, null=True, blank=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, serialize=True) + account = models.ForeignKey(Account, on_delete=models.CASCADE, serialize=True, null=True, blank=True) + time_created = models.DateTimeField(default=timezone.now, serialize=True) + schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE, null=True, blank=True, serialize=True, related_name='assoc_sch') + expressions = models.JSONField(serialize=True, null=True, blank=True, default=get_expressions_default) + actions = models.JSONField(serialize=True, null=True, blank=True, default=get_actions_default) + + def __str__(self): + return f'{self.id}' + @@ -443,7 +690,7 @@ class Mask(models.Model): mask_id = models.CharField(max_length=1000, serialize=True, null=True, blank=True) def __str__(self): - return f'{self.id}__mask' + return f'{self.id}_mask' @@ -451,15 +698,19 @@ def __str__(self): class Process(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) site = models.ForeignKey(Site, on_delete=models.CASCADE, null=True, blank=True, serialize=True) - type = models.CharField(max_length=1000, serialize=True, null=True, blank=True) + account = models.ForeignKey(Account, on_delete=models.CASCADE, null=True, blank=True, serialize=True) + type = models.CharField(max_length=1000, serialize=True, null=True, blank=True) + object_id = models.CharField(max_length=1000, serialize=True, null=True, blank=True) time_created = models.DateTimeField(default=timezone.now, serialize=True) time_completed = models.DateTimeField(serialize=True, null=True, blank=True) - successful = models.BooleanField(serialize=True, default=False) + success = models.BooleanField(serialize=True, default=False) + exception = models.TextField(serialize=True, null=True, blank=True) + info = models.JSONField(serialize=True, null=True, blank=True) info_url = models.CharField(max_length=1000, serialize=True, null=True, blank=True) progress = models.FloatField(serialize=True, null=True, blank=True) def __str__(self): - return f'{self.id}__process' + return f'{self.id}_process' @@ -475,4 +726,7 @@ class Log(models.Model): response_payload = models.JSONField(serialize=True, null=True, blank=True) def __str__(self): - return f'{self.status}__{self.request_type}__{self.path}' + return f'{self.status}_{self.request_type}_{self.path}' + + + diff --git a/app/api/signals.py b/app/api/signals.py new file mode 100644 index 00000000..d799cdca --- /dev/null +++ b/app/api/signals.py @@ -0,0 +1,26 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from .utils.flowr import Flowr +from .models import * +from cursion import settings + + + + + + +@receiver(post_save, sender=FlowRun) +def flowrun_created(sender, instance, created, **kwargs): + + # defing instance as new flowrun + flowrun = instance + + # check location + if settings.LOCATION == 'us': + + # init Flowr & execute run_next() + Flowr(flowrun_id=str(flowrun.id)).run_next() + + # return None + return None + \ No newline at end of file diff --git a/app/api/tasks.py b/app/api/tasks.py index d242b559..466060da 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -1,204 +1,3196 @@ -from __future__ import absolute_import, unicode_literals -from typing import Any from celery.utils.log import get_task_logger -from celery import shared_task -from celery import Task as BaseTask -from .v1.ops.tasks import ( - create_site_task, create_scan_task, run_html_and_logs_task, - run_vrt_task, run_lighthouse_task, run_yellowlab_task, - create_test_task, create_report_task, delete_report_s3, - delete_site_s3, create_testcase_task, migrate_site_task, - delete_testcase_s3, +from celery import shared_task, Task +from cursion import celery +from .utils.crawler import Crawler +from .utils.scanner import Scanner as S +from .utils.tester import Tester as T +from .utils.reporter import Reporter as R +from .utils.wordpress import Wordpress as W +from .utils.alerter import Alerter +from .utils.caser import Caser +from .utils.autocaser import AutoCaser +from .utils.exporter import create_and_send_report_export +from .utils.scanner import ( + _html_and_logs, _vrt, _lighthouse, + _yellowlab +) +from .utils.alerts import * +from .utils.updater import update_flowrun +from .models import * +from django.contrib.auth.models import User +from django.utils import timezone +from datetime import datetime, timedelta, timezone +from redis import Redis +from contextlib import contextmanager +from cursion import settings +import asyncio, boto3, time, requests, \ +json, stripe, inspect, random, secrets + + + + + + +class BaseTaskWithRetry(Task): + autoretry_for = (Exception, KeyError) + retry_kwargs = {'max_retries': int(settings.MAX_ATTEMPTS - 1)} + retry_backoff = True + + + + +# setting logger +logger = get_task_logger(__name__) + + + + +# setting redis client +redis_client = Redis.from_url(settings.CELERY_BROKER_URL) + + + + +# setting locking manager to prevent duplicate tasks +@contextmanager +def task_lock(lock_name, timeout=300): + lock = redis_client.lock(lock_name, timeout=timeout) + acquired = lock.acquire(blocking=False) + print(f"Lock {'acquired' if acquired else 'not acquired'} for {lock_name}") + try: + yield acquired + finally: + if acquired: + lock.release() + print(f"Lock released for {lock_name}") + + + + +# setting s3 instance +def s3(): + s3 = boto3.resource('s3', + aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), + aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), + region_name=str(settings.AWS_S3_REGION_NAME), + endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) + ) + return s3 + + + + +def check_and_increment_resource(account: object, resource: str) -> bool: + """ + Adds 1 to the Account.usage.{resource} if + {resource}_allowed has not been reached. + + Expcets: { + 'account' : , + 'resource' : 'scan', 'test', 'caserun', etc + } + + Returns: Bool, True if resource was incremented. + """ + + # define defaults + success = False + + # check allowance + if (int(account.usage[f'{resource}']) + 1) <= int(account.usage[f'{resource}_allowed']): + + # increment and update success + account.usage[f'{resource}'] = 1 + int(account.usage[f'{resource}']) + account.save() + success = True + + # return response + return success + + + + +def check_location(location: str) -> bool: + """ + Determines if task should be executed based on + passed location and current system location (settings.LOCATION). + + Expects: { + 'location': str + } + + Returns: bool (True if task should run) + """ + + # compare location to system + if location == settings.LOCATION: + return True + if location != settings.LOCATION: + return False + + + + +def record_task( + resource_type: str=None, + resource_id: str=None, + task_id: str=None, + task_method: str=None, + **kwargs, + ) -> None: + + """ + Records task information in the `resource.system` + attribute. + + Expects: { + 'resource_type' : str (scan, test, caserun) + 'resource_id' : str + 'task_id' : str + 'task_method' : str + 'kwargs' : dict + } + + Returns: None + """ + + # get resource + if resource_type == 'scan': + resource = Scan.objects.get(id=resource_id) + if resource_type == 'test': + resource = Test.objects.get(id=resource_id) + if resource_type == 'caserun': + resource = CaseRun.objects.get(id=resource_id) + + # get current resoruce.system.tasks data + tasks = resource.system.get('tasks', []) + + # get component based on task_name + component = task_method.replace('run_', '').replace('_bg', '').replace('_and_logs', '') + + # check if task exists + i = 0 + exists = False + for task in tasks: + if task['component'] == component: + # update existing task + tasks[i]['task_id'] = str(task_id) + tasks[i]['attempts'] += 1 + exists = True + i += 1 + + # append new task data + if not exists: + tasks.append({ + 'attempts': int(1), + 'task_id': str(task_id), + 'task_method': str(task_method), + 'component': str(component), + 'kwargs': kwargs.get('kwargs'), + }) + + # update resource with new system data + resource.system['tasks'] = tasks + resource.save() + + # return + return None + + + + +@shared_task() +def redeliver_failed_tasks() -> None: + """ + Check each un-completed resource (mainly scans for now) + for any celery tasks which are no longer executing & + associated resource.component is null. Once found, + re-run those specific tasks with saved kwargs. + + Expects: None + + Returns: None + """ + + # get uncompleted Scans + scans = Scan.objects.filter(time_completed=None) + + # get executing_tasks + i = celery.app.control.inspect() + reserved = i.reserved() + active = i.active() + executing_tasks = [] + for replica in reserved: + for task in reserved[replica]: + executing_tasks.append(task['id']) + for replica in active: + for task in active[replica]: + executing_tasks.append(task['id']) + + # iterate through each scan and re-run any failed jobs + for scan in scans: + + # check for localization + if scan.configs.get('location', 'us') != settings.LOCATION: + continue + + # check each task in system['tasks'] + for task in scan.system.get('tasks', []): + + # get scan.{component} data + if task['component'] == 'yellowlab': + component = scan.yellowlab.get('audits', None) + if task['component'] == 'lighthouse': + component = scan.lighthouse.get('audits', None) + if task['component'] == 'vrt': + component = scan.images + if task['component'] == 'html': + component = scan.html + + # re-run task if not in executing_tasks & + # scan.{component} is None + if task['task_id'] not in executing_tasks and component is None: + + # check for max attempts + if task['attempts'] < settings.MAX_ATTEMPTS: + print(f're-running -> {task["task_method"]}.delay(**{task["kwargs"]})') + eval(f'{task["task_method"]}.delay(**{task["kwargs"]})') + + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def create_site_and_pages_bg(self, site_id: str=None, configs: dict=settings.CONFIGS) -> None: + """ + Takes a newly created `Site`, initiates a Crawl and + initial `Scan` for each crawled page + + Expcets: { + site_id: str, + configs: dict + } + + Returns -> None + """ + + # getting site and updating for time_crawl_start + site = Site.objects.get(id=site_id) + site.time_crawl_started = datetime.now(timezone.utc) + site.time_crawl_completed = None + site.save() + + # get max_urls + max_urls = site.account.usage['pages_allowed'] + + # crawl site + pages = Crawler(url=site.site_url, max_urls=max_urls).get_links() + + # create pages and scans + for url in pages: + + # add new page + if not Page.objects.filter(site=site, page_url=url).exists(): + page = Page.objects.create( + site=site, + page_url=url, + user=site.user, + account=site.account, + ) + + # check resouce allowance + if check_and_increment_resource(site.account, 'scans'): + + # create initial scan + scan = Scan.objects.create( + site=site, + page=page, + type=settings.TYPES, + configs=configs + ) + + # run each scan component in parallel + run_html_and_logs_bg.delay(scan_id=scan.id) + run_lighthouse_bg.delay(scan_id=scan.id) + run_yellowlab_bg.delay(scan_id=scan.id) + run_vrt_bg.delay(scan_id=scan.id) + + # update page info + page.info["latest_scan"]["id"] = str(scan.id) + page.info["latest_scan"]["time_created"] = str(scan.time_created) + page.save() + + # updating site status + site.time_crawl_completed = datetime.now(timezone.utc) + site.save() + + logger.info('Added site and all pages') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def crawl_site_bg(self, site_id: str=None, configs: dict=settings.CONFIGS) -> None: + """ + Takes an existing `Site`, initiates a new Crawl and + initial `Scan` for each newly added page + + Expcets: { + site_id: str, + configs: dict + } + + Returns -> None + """ + + # getting site and updating for time_crawl_start + site = Site.objects.get(id=site_id) + site.time_crawl_started = datetime.now(timezone.utc) + site.time_crawl_completed = None + site.save() + + # get pages_allowed + pages_allowed = site.account.usage['pages_allowed'] + + # getting old pages for comparison + old_pages = Page.objects.filter(site=site) + old_urls = [] + for p in old_pages: + old_urls.append(p.page_url) + + # crawl site + new_urls = Crawler(url=site.site_url, max_urls=pages_allowed).get_links() + add_urls = [] + + # checking for duplicates + for url in new_urls: + if not url in old_urls: + add_urls.append(url) + + # loop thorugh crawled pages + # and add if not present + current_count = len(old_urls) + for url in add_urls: + + # add new page if room exists + if current_count < pages_allowed: + page = Page.objects.create( + site=site, + page_url=url, + user=site.user, + account=site.account, + ) + + # check resouce allowance + if check_and_increment_resource(site.account, 'scans'): + + # create initial scan + scan = Scan.objects.create( + site=site, + page=page, + type=settings.TYPES, + configs=configs + ) + # run each scan component in parallel + run_html_and_logs_bg.delay(scan_id=scan.id) + run_lighthouse_bg.delay(scan_id=scan.id) + run_yellowlab_bg.delay(scan_id=scan.id) + run_vrt_bg.delay(scan_id=scan.id) + page.info["latest_scan"]["id"] = str(scan.id) + page.info["latest_scan"]["time_created"] = str(scan.time_created) + page.save() + + # increment + current_count += 1 + + # updating site status + site.time_crawl_completed = datetime.now(timezone.utc) + site.save() + + logger.info('crawled site and added pages') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def update_site_and_page_info( + self, + resource: str='all', + site_id: str=None, + page_id: str=None, + ) -> None: + """ + Updates the site and or page `latest_scan` & `latest_test` info + depending on scope. + + Expects: { + "resource" : str (OPTIONAL), + "site_id" : str (OPTIONAL), + "page_id" : str (OPTIONAL) + } + + Returns -> None + """ + + # defaults + site = None + page = None + pages = [] + scans = [] + tests = [] + latest_test = None + latest_scan = None + + # get associated site + if site_id: + site = Site.objects.get(id=site_id) + pages = Page.objects.filter(site=site) + + # get associated page + if page_id: + page = Page.objects.get(id=page_id) + site = page.site + pages = Page.objects.filter(site=site) + + # get latest tests & scans of pages + for p in pages: + + if Test.objects.filter(page=p).exists() and \ + (resource == 'test' or resource == 'all'): + _test = Test.objects.filter(page=p).exclude( + time_completed=None + ).order_by('-time_completed') + if len(_test) > 0: + if _test[0].score: + # add to tests[] + tests.append(_test[0].score) + # update latest_test + latest_test = _test[0] + + if Scan.objects.filter(page=p).exists()and \ + (resource == 'scan' or resource == 'all'): + _scan = Scan.objects.filter(page=p).exclude( + time_completed=None + ).order_by('-time_completed') + if len(_scan) > 0: + if _scan[0].score: + # add to scans[] + scans.append(_scan[0].score) + # update latest_scan + latest_scan = _scan[0] + + # update single page if passed + if page: + + # checking if current p is page + if page == p: + + # latest_scan info + if latest_scan: + page.info['latest_scan']['id'] = str(latest_scan.id) + page.info['latest_scan']['time_created'] = str(latest_scan.time_created) + page.info['latest_scan']['time_completed'] = str(latest_scan.time_completed) + page.info['latest_scan']['score'] = latest_scan.score + page.info['lighthouse'] = latest_scan.lighthouse.get('scores') + page.info['yellowlab'] = latest_scan.yellowlab.get('scores') + if latest_scan is None and (resource == 'scan' or resource == 'all'): + page.info['latest_scan']['id'] = None + page.info['latest_scan']['time_created'] = None + page.info['latest_scan']['time_completed'] = None + page.info['latest_scan']['score'] = None + page.info['lighthouse'] = None + page.info['yellowlab'] = None + + # latest_test info + if latest_test: + page.info['latest_test']['id'] = str(latest_test.id) + page.info['latest_test']['time_created'] = str(latest_test.time_created) + page.info['latest_test']['time_completed'] = str(latest_test.time_completed) + page.info['latest_test']['score'] = (round(latest_test.score * 100) / 100) + page.info['latest_test']['status'] = latest_test.status + if latest_test is None and (resource == 'test' or resource == 'all'): + page.info['latest_test']['id'] = None + page.info['latest_test']['time_created'] = None + page.info['latest_test']['time_completed'] = None + page.info['latest_test']['score'] = None + page.info['latest_test']['status'] = None + + # save page + page.save() + + # update site with new scan info + if len(scans) > 0: + # calc site average of latest_scan.score + site_avg_scan_score = round((sum(scans)/len(scans)) * 100) / 100 + print(f'updating site with new scan score -> {site_avg_scan_score}') + + # latest_scan info + if latest_scan: + site.info['latest_scan']['id'] = str(latest_scan.id) + site.info['latest_scan']['time_created'] = str(latest_scan.time_created) + site.info['latest_scan']['time_completed'] = str(latest_scan.time_completed) + site.info['latest_scan']['score'] = latest_scan.score + site.info['lighthouse'] = latest_scan.lighthouse.get('scores') + site.info['yellowlab'] = latest_scan.yellowlab.get('scores') + if latest_scan is None and (resource == 'scan' or resource == 'all'): + site.info['latest_scan']['id'] = None + site.info['latest_scan']['time_created'] = None + site.info['latest_scan']['time_completed'] = None + site.info['latest_scan']['score'] = None + site.info['lighthouse'] = None + site.info['yellowlab'] = None + + # update site with new test info + if len(tests) > 0: + # calc site average of latest_test.score + site_avg_test_score = round((sum(tests)/len(tests)) * 100) / 100 + print(f'updating site with new test score -> {site_avg_test_score}') + + # update site info + if latest_test: + site.info['latest_test']['id'] = str(latest_test.id) + site.info['latest_test']['time_created'] = str(latest_test.time_created) + site.info['latest_test']['time_completed'] = str(latest_test.time_completed) + site.info['latest_test']['score'] = site_avg_test_score + site.info['latest_test']['status'] = latest_test.status + if latest_test is None and (resource == 'test' or resource == 'all'): + site.info['latest_test']['id'] = None + site.info['latest_test']['time_created'] = None + site.info['latest_test']['time_completed'] = None + site.info['latest_test']['score'] = None + site.info['latest_test']['status'] = None + + # save info + site.save() + + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def update_scan_score(self, scan_id: str) -> None: + """ + Method to calculate the average health score and update + for the passed scan_id + + Expects: { + 'scan_id': str + } + + Returns -> None + """ + + # setting defaults + score = None + scores = [] + scan = Scan.objects.get(id=scan_id) + + # get latest scan scores + if scan.lighthouse['scores']['average'] is not None: + scans.append(scan.lighthouse['scores']['average']) + if scan.yellowlab['scores']['globalScore'] is not None: + scans.append(scan.yellowlab['scores']['globalScore']) + + # calc average score + if len(scores) > 0: + score = sum(scores)/len(scores) + + # save to scan + scan.score = score + scan.save() + + # returning scan + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def scan_page_bg( + self, + scan_id: str=None, + test_id: str=None, + alert_id: str=None, + configs: dict=settings.CONFIGS, + flowrun_id: str=None, + node_index: str=None, + ) -> None: + """ + Runs all the requested `Scan` components + of the passed `Scan`. + + Expects: { + scan_id : str, + test_id : str, + alert_id : str, + configs : dict, + flowrun_id : str, + node_index : str + } + + Returns -> None + """ + + # get scan object + scan = Scan.objects.get(id=scan_id) + + # run each scan component in parallel + if 'html' in scan.type or 'logs' in scan.type or 'full' in scan.type: + run_html_and_logs_bg.delay( + scan_id=scan.id, + test_id=test_id, + alert_id=alert_id, + flowrun_id=flowrun_id, + node_index=node_index, + ) + if 'lighthouse' in scan.type or 'full' in scan.type: + run_lighthouse_bg.delay( + scan_id=scan.id, + test_id=test_id, + alert_id=alert_id, + flowrun_id=flowrun_id, + node_index=node_index, + ) + if 'yellowlab' in scan.type or 'full' in scan.type: + run_yellowlab_bg.delay( + scan_id=scan.id, + test_id=test_id, + alert_id=alert_id, + flowrun_id=flowrun_id, + node_index=node_index, + ) + if 'vrt' in scan.type or 'full' in scan.type: + run_vrt_bg.delay( + scan_id=scan.id, + test_id=test_id, + alert_id=alert_id, + flowrun_id=flowrun_id, + node_index=node_index, + ) + + logger.info('created new Scan of Page') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def create_scan( + self, + scan_id: str=None, + page_id: str=None, + type: list=settings.TYPES, + alert_id: str=None, + configs: str=None, + tags: str=None, + ) -> None: + """ + Runs a `Scan` using Scanner.build_scan() + where each component is run in sequence. + + Expects: { + scan_id : str, + page_id : str, + type : list, + alert_id : str, + configs : dict, + tags : list, + } + + Returns -> None + """ + + # get scan if scan_id present + if scan_id is not None: + created_scan = Scan.objects.get(id=scan_id) + + # create scan if page_id present + elif page_id is not None: + page = Page.objects.get(id=page_id) + created_scan = Scan.objects.create( + site=page.site, + page=page, + type=type, + configs=configs, + tags=tags, + ) + + # run scan and alert if necessary + scan = S(scan=created_scan).build_scan() + if alert_id: + print('running alert from `task.create_scan`') + Alerter(alert_id, scan.id).run_alert() + + logger.info('Created new scan of site') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def create_scan_bg(self, *args, **kwargs) -> None: + """ + Creates 1 or more `Scans` depending on + the scope (page, site or account). Used with `Schedules` + + Expects: { + 'scope' : str + 'resources' : list + 'account_id' : strx + 'type' : list, + 'configs' : dict, + 'tags' : list, + 'alert_id' : str, + 'task_id' : str, + 'flowrun_id' : str, + 'node_index : str + } + + Returns -> None + """ + + # get data from kwargs + scope = kwargs.get('scope') + resources = kwargs.get('resources') + account_id = kwargs.get('account_id') + type = kwargs.get('type') + configs = kwargs.get('configs') + tags = kwargs.get('tags') + alert_id = kwargs.get('alert_id') + task_id = kwargs.get('task_id') + flowrun_id = kwargs.get('flowrun_id') + node_index = kwargs.get('node_index') + + # check for redis lock + redis_id = task_id if task_id else secrets.token_hex(8) + lock_name = f"lock:create_scan_bg_{redis_id}" + with task_lock(lock_name) as lock_acquired: + + # checking if task is already running + if not lock_acquired: + logger.info('task is already running, skipping execution.') + return None + + # checking location + if not check_location(configs.get('location', settings.LOCATION)): + logger.info('Not running due to location param') + return None + + # setting defaults + pages = [] + sites = [] + objects = [] + + # get account if account_id exists + if account_id: + account = Account.objects.get(id=account_id) + + # iterating through resources + # and adding to sites or pages + if len(resources) > 0: + for item in resources: + + # adding to pages + if item['type'] == 'page': + try: + pages.append( + Page.objects.get(id=item['id']) + ) + except Exception as e: + print(e) + + # adding to sites + if item['type'] == 'site': + try: + sites.append( + Site.objects.get(id=item['id']) + ) + except Exception as e: + print(e) + + # grabbing all sites because no + # resources were specified and scope is "account" + if len(resources) == 0 and scope == 'account': + sites = Site.objects.filter(account=account) + + # get all pages from existing sites + for site in sites: + pages += Page.objects.filter(site=site) + + # creating scans for each page + for page in pages: + + # check resource + if check_and_increment_resource(page.account, 'scans'): + + # create Scan obj + scan = Scan.objects.create( + site=page.site, + page=page, + type=type, + tags=tags, + configs=configs, + ) + + # updating latest_scan info for page + page.info['latest_scan']['id'] = str(scan.id) + page.info['latest_scan']['time_created'] = str(datetime.now(timezone.utc)) + page.info['latest_scan']['time_completed'] = None + page.info['latest_scan']['score'] = None + page.info['latest_scan']['score'] = None + page.save() + + # updating latest_scan info for site + page.site.info['latest_scan']['id'] = str(scan.id) + page.site.info['latest_scan']['time_created'] = str(datetime.now(timezone.utc)) + page.site.info['latest_scan']['time_completed'] = None + page.site.save() + + # adding objects + objects.append({ + 'parent': str(scan.page.id), + 'id': str(scan.id), + 'status': 'working' + }) + + # init scan page in background + scan_page_bg.delay( + scan_id=str(scan.id), + alert_id=alert_id, + configs=configs, + flowrun_id=flowrun_id, + node_index=node_index + ) + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'objects': objects, + 'node_status': 'working' if len(objects) > 0 else 'failed', + 'message': f'starting {len(objects)} scans for {page.site.site_url} | run_id: {flowrun_id}' + }) + + # update schedule if task_id is not None + if task_id: + try: + last_run = datetime.now(timezone.utc) + Schedule.objects.filter(periodic_task_id=task_id).update( + time_last_run=last_run + ) + except Exception as e: + print(e) + + logger.info('created new Scans') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def run_html_and_logs_bg( + self, + scan_id: str=None, + test_id: str=None, + alert_id: str=None, + flowrun_id: str=None, + node_index: str=None, + **kwargs + ) -> None: + """ + Runs the html & logs components of the passed `Scan` + + Expects: { + scan_id : str, + test_id : str, + alert_id : str, + flowrun_id : str, + node_index : str, + **kwargs + } + + Returns -> None + """ + + # sleeping random for DB + time.sleep(random.uniform(2, 6)) + + # get kwargs data if no scan_id + if scan_id is None: + scan_id = kwargs.get('scan_id') + test_id = kwargs.get('test_id') + alert_id = kwargs.get('alert_id') + flowrun_id = kwargs.get('flowrun_id') + node_index = kwargs.get('node_index') + + # save sys data + record_task( + resource_type='scan', + resource_id=str(scan_id), + task_id=str(self.request.id), + task_method=str(inspect.stack()[0][3]), + kwargs={ + 'scan_id': str(scan_id) if scan_id is not None else None, + 'test_id': str(test_id) if test_id is not None else None, + 'alert_id': str(alert_id) if alert_id is not None else None, + 'flowrun_id': str(flowrun_id) if flowrun_id is not None else None, + 'node_index': str(node_index) if node_index is not None else None + } + ) + + # run html and logs component + _html_and_logs(scan_id, test_id, alert_id, flowrun_id, node_index) + + logger.info('ran html & logs component') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def run_vrt_bg( + self, + scan_id: str=None, + test_id: str=None, + alert_id: str=None, + flowrun_id: str=None, + node_index: str=None, + **kwargs + ) -> None: + """ + Runs the VRT component of the passed `Scan` + + Expects: { + scan_id : str, + test_id : str, + alert_id : str, + flowrun_id : str, + node_index : str, + **kwargs + } + + Returns -> None + """ + + # sleeping random for DB + time.sleep(random.uniform(2, 6)) + + # get kwargs data if no scan_id + if scan_id is None: + scan_id = kwargs.get('scan_id') + test_id = kwargs.get('test_id') + alert_id = kwargs.get('alert_id') + flowrun_id = kwargs.get('flowrun_id') + node_index = kwargs.get('node_index') + + # save sys data + record_task( + resource_type='scan', + resource_id=str(scan_id), + task_id=str(self.request.id), + task_method=str(inspect.stack()[0][3]), + kwargs={ + 'scan_id': str(scan_id) if scan_id is not None else None, + 'test_id': str(test_id) if test_id is not None else None, + 'alert_id': str(alert_id) if alert_id is not None else None, + 'flowrun_id': str(flowrun_id) if flowrun_id is not None else None, + 'node_index': str(node_index) if node_index is not None else None + } + ) + + # run VRT component + _vrt(scan_id, test_id, alert_id, flowrun_id, node_index) + + logger.info('ran vrt component') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def run_lighthouse_bg( + self, + scan_id: str=None, + test_id: str=None, + alert_id: str=None, + flowrun_id: str=None, + node_index: str=None, + **kwargs + ) -> None: + """ + Runs the lighthouse component of the passed `Scan` + + Expects: { + scan_id : str, + test_id : str, + alert_id : str, + flowrun_id : str, + node_index : str, + **kwargs + } + + Returns -> None + """ + + # sleeping random for DB + time.sleep(random.uniform(2, 6)) + + # get kwargs data if no scan_id + if scan_id is None: + scan_id = kwargs.get('scan_id') + test_id = kwargs.get('test_id') + alert_id = kwargs.get('alert_id') + flowrun_id = kwargs.get('flowrun_id') + node_index = kwargs.get('node_index') + + # save sys data + record_task( + resource_type='scan', + resource_id=str(scan_id), + task_id=str(self.request.id), + task_method=str(inspect.stack()[0][3]), + kwargs={ + 'scan_id': str(scan_id) if scan_id is not None else None, + 'test_id': str(test_id) if test_id is not None else None, + 'alert_id': str(alert_id) if alert_id is not None else None, + 'flowrun_id': str(flowrun_id) if flowrun_id is not None else None, + 'node_index': str(node_index) if node_index is not None else None + } + ) + + # run lighthouse component + _lighthouse(scan_id, test_id, alert_id, flowrun_id, node_index) + + logger.info('ran lighthouse component') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def run_yellowlab_bg( + self, + scan_id: str=None, + test_id: str=None, + alert_id: str=None, + flowrun_id: str=None, + node_index: str=None, + **kwargs + ) -> None: + """ + Runs the yellowlab component of the passed `Scan` + + Expects: { + scan_id : str, + test_id : str, + alert_id : str, + flowrun_id : str, + node_index : str, + **kwargs + } + + Returns -> None + """ + + # sleeping random for DB + time.sleep(random.uniform(2, 6)) + + # get kwargs data if no scan_id + if scan_id is None: + scan_id = kwargs.get('scan_id') + test_id = kwargs.get('test_id') + alert_id = kwargs.get('alert_id') + flowrun_id = kwargs.get('flowrun_id') + node_index = kwargs.get('node_index') + + # save sys data + record_task( + resource_type='scan', + resource_id=str(scan_id), + task_id=str(self.request.id), + task_method=str(inspect.stack()[0][3]), + kwargs={ + 'scan_id': str(scan_id) if scan_id is not None else None, + 'test_id': str(test_id) if test_id is not None else None, + 'alert_id': str(alert_id) if alert_id is not None else None, + 'flowrun_id': str(flowrun_id) if flowrun_id is not None else None, + 'node_index': str(node_index) if node_index is not None else None + } + ) + + # run yellowlab component + _yellowlab(scan_id, test_id, alert_id, flowrun_id, node_index) + + logger.info('ran yellowlab component') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def run_test(self, test_id: str, alert_id: str=None) -> None: + """ + Helper function to shorted the code base + when creating a `Test`. + + Expects: { + test_id : str, + alert_id : str + } + + Returns -> None + """ + # get test + test = Test.objects.get(id=test_id) + + # execute test + test = T(test=test).run_test() + if alert_id: + print('running alert from `task.run_test`') + Alerter(alert_id, test.id).run_alert() + + logger.info('Test completed') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def create_test( + self, + test_id: str=None, + page_id: str=None, + alert_id: str=None, + configs: dict=settings.CONFIGS, + type: list=settings.TYPES, + index: int=None, + pre_scan: str=None, + post_scan: str=None, + tags: list=None, + threshold: float=settings.TEST_THRESHOLD, + flowrun_id: str=None, + node_index: str=None + ) -> None: + """ + Creates a `post_scan` if necessary, waits for completion, + and runs a `Test` + + Expects: { + test_id : str, + page_id : str, + alert_id : str, + configs : dict, + type : list, + index : int, + pre_scan : str, + post_scan : str, + tags : list, + threshold : float, + flowrun_id : str, + node_index : str + } + + Returns -> None + """ + + # setting defaults + created_test = None + objects = [] + + # get or create a Test + if test_id is not None: + created_test = Test.objects.get(id=test_id) + page = created_test.page + elif page_id is not None: + page = Page.objects.get(id=page_id) + created_test = Test.objects.create( + site=page.site, + page=page, + type=type, + tags=tags, + threshold=float(threshold), + status='working' + ) + + # adding objects + objects.append({ + 'parent': str(page.id), + 'id': str(created_test.id), + 'status': 'working' + }) + + # get pre_ & post_ scans + if pre_scan is not None: + pre_scan = Scan.objects.get(id=pre_scan) + if post_scan is not None: + post_scan = Scan.objects.get(id=post_scan) + if post_scan is None or pre_scan is None: + if pre_scan is None: + # check for pre_scan existance + if not Scan.objects.filter(page=page).exclude(time_completed=None).exists(): + + # create new scan if none exists + new_scan = Scan.objects.create( + site=page.site, + page=page, + tags=tags, + type=type, + configs=configs, + ) + scan_page_bg.delay( + scan_id=new_scan.id, + configs=configs, + ) + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'objects': objects, + 'message': ( + f'❌ test for {page.page_url} could not start because there was '+ + f'no pre_scan available - starting new scan instead' + ) + }) + + # remove created_test + created_test.delete() + + # return None + logger.info('no pre_scan available to create Test with') + return None + + # get pre_scan if exists + pre_scan = Scan.objects.filter( + page=page + ).exclude( + time_completed=None + ).order_by('-time_completed')[0] + + # check and increment resources + if not check_and_increment_resource(page.account, 'scans'): + + # update obects + objects[-1]['status'] = 'failed' + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'objects': objects, + 'message': ( + f'❌ test for {page.page_url} could not start because this account has reached '+ + f'max_allowed_scans for this billing cycle' + ) + }) + + # remove created_test + created_test.delete() + + # return None + logger.info('no more scans usage available') + return None + + # create new post_scan + post_scan = Scan.objects.create( + site=page.site, + page=page, + tags=tags, + type=type, + configs=configs, + ) + + # run Scan & Test tasks + scan_page_bg.delay( + scan_id=post_scan.id, + test_id=created_test.id, + alert_id=alert_id, + configs=configs, + flowrun_id=flowrun_id, + node_index=node_index + ) + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'objects': objects, + 'message': ( + f'test starting for {page.page_url} | '+ + f'run_id: {flowrun_id}' + ) + }) + + # updating parired scans + pre_scan.paired_scan = post_scan + post_scan.paried_scan = pre_scan + pre_scan.save() + post_scan.save() + + # updating test object + created_test.type = type + created_test.pre_scan = pre_scan + created_test.post_scan = post_scan + created_test.save() + + # check if pre and post scan are complete and start test if True + if pre_scan.time_completed is not None and post_scan.time_completed is not None: + run_test.delay(test_id=created_test.id, alert_id=alert_id) + + logger.info('Began Scan/Test process') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def create_test_bg(self, *args, **kwargs) -> None: + """ + Depending on the scope, run create_test() for + all requested pages. + + Expects: { + scope : str + resources : list + account_id : str + test_id : str + type : list + configs : dict + tags : list + alert_id : str + pre_scan : str + post_scan : str + threshold : float + task_id : str + flowrun_id : str + node_index : str + } + + Returns -> None + """ + + # get data + scope = kwargs.get('scope') + resources = kwargs.get('resources', []) + account_id = kwargs.get('account_id') + test_id = kwargs.get('test_id') + type = kwargs.get('type') + configs = kwargs.get('configs') + tags = kwargs.get('tags') + threshold = kwargs.get('threshold') + alert_id = kwargs.get('alert_id') + pre_scan = kwargs.get('pre_scan') + post_scan = kwargs.get('post_scan') + task_id = kwargs.get('task_id') + flowrun_id = kwargs.get('flowrun_id') + node_index = kwargs.get('node_index') + + # check for redis lock + redis_id = task_id if task_id else secrets.token_hex(8) + lock_name = f"lock:create_test_bg_{redis_id}" + with task_lock(lock_name) as lock_acquired: + + # checking if task is already running + if not lock_acquired: + logger.info('task is already running, skipping execution.') + return None + + # checking location + if not check_location(configs.get('location', settings.LOCATION)): + logger.info('Not running due to location param') + return None + + # create test if none was passed + if test_id is None: + + # setting defaults + pages = [] + sites = [] + objects = [] + failed = 0 + + # get account if account_id exists + if account_id: + account = Account.objects.get(id=account_id) + + # iterating through resources + # and adding to sites or pages + if len(resources) > 0: + for item in resources: + + # adding to pages + if item['type'] == 'page': + try: + pages.append( + Page.objects.get(id=item['id']) + ) + except Exception as e: + print(e) + + # adding to sites + if item['type'] == 'site': + try: + sites.append( + Site.objects.get(id=item['id']) + ) + except Exception as e: + print(e) + + # grabbing all sites because no + # resources were specified and scope is "account" + if len(resources) == 0 and scope == 'account': + sites = Site.objects.filter(account=account) + + # get all pages from existing sites + for site in sites: + pages += Page.objects.filter(site=site) + + # create a test for each page + for page in pages: + + objects.append({ + 'parent': str(page.id), + 'id': None, + 'status': 'working' + }) + + # check resource + if check_and_increment_resource(page.account, 'tests'): + + # updating latest_test info for page + page.info['latest_test']['id'] = 'placeholder' + page.info['latest_test']['time_created'] = str(datetime.now(timezone.utc)) + page.info['latest_test']['time_completed'] = None + page.info['latest_test']['score'] = None + page.info['latest_test']['status'] = 'working' + page.save() + + # updating latest_test info for site + page.site.info['latest_test']['id'] = 'placeholder' + page.site.info['latest_test']['time_created'] = str(datetime.now(timezone.utc)) + page.site.info['latest_test']['time_completed'] = None + page.site.info['latest_test']['score'] = None + page.site.info['latest_test']['status'] = 'working' + page.site.save() + + # create test + create_test.delay( + page_id=str(page.id), + type=type, + configs=configs, + tags=tags, + threshold=float(threshold), + pre_scan=pre_scan, + post_scan=post_scan, + alert_id=str(alert_id), + flowrun_id=str(flowrun_id), + node_index=node_index + ) + + else: + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': ( + f'❌ test for {page.page_url} could not start because this account has reached '+ + f'max_allowed_tests for this billing cycle' + ) + }) + + # update last object + failed += 1 + objects[-1]['status'] = 'failed' + logger.info('maxed tests reached') + return None + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'objects': objects, + 'node_status': 'working', + 'message': f'created {str(len(objects) - failed)} tests for {page.site.site_url} | run_id: {flowrun_id}' + }) + + # get test and run + if test_id: + test = Test.objects.get(id=test_id) + create_test.delay( + test_id=str(test_id), + page_id=str(test.page.id), + type=type, + configs=configs, + tags=tags, + threshold=float(threshold), + pre_scan=pre_scan, + post_scan=post_scan, + alert_id=str(alert_id), + flowrun_id=str(flowrun_id), + node_index=node_index + ) + + # update schedule if task_id is not None + if task_id: + try: + last_run = datetime.now(timezone.utc) + Schedule.objects.filter(periodic_task_id=task_id).update( + time_last_run=last_run + ) + except Exception as e: + print(e) + + logger.info('Created new Tests') + return None + + + + +@shared_task +def create_report( + page_id: str=None, + alert_id: str=None, + flowrun_id: str=None, + node_index: str=None + ) -> None: + """ + Generates a new PDF `Report` of the requested `Page` + and runs the associated `Alert` if requested + + Expcets: { + page_id : str, + alert_id : str, + flowrun_id : str + node_index : str + } + + Returns -> None + """ + + # get page + page = Page.objects.get(id=page_id) + + # create report obj + info = { + "text_color": '#24262d', + "background_color": '#e1effd', + "highlight_color": '#ffffff', + } + report = Report.objects.create( + user=page.user, + site=page.site, + account=page.account, + page=page, + info=info, + type=['lighthouse', 'yellowlab'] + ) + + # generate report PDF + resp = R(report=report).generate_report() + + # run alert + if alert_id: + Alerter(alert_id, str(report.id)).run_alert() + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': f'report {'created' if resp['success'] else 'not created'} for {page.page_url} | report_id: {str(report.id)}', + 'objects': [{ + 'parent': str(page.id), + 'id': str(report.id), + 'status': 'passed' if resp['success'] else 'failed' + }] + }) + + logger.info('Created new report of page') + return None + + + + +@shared_task +def create_report_bg(*args, **kwargs) -> None: + """ + Creates new `Reports` for the requested `Pages` + + Expects: { + 'scope' : str, + 'resources' : str + 'account_id' : str + 'alert_id' : str + 'task_id' : str + 'flowrun_id' : str + 'node_index' : str + } + + Returns -> None + """ + + # get data + scope = kwargs.get('scope') + resources = kwargs.get('resources', []) + account_id = kwargs.get('account_id') + alert_id = kwargs.get('alert_id') + task_id = kwargs.get('task_id') + flowrun_id = kwargs.get('flowrun_id') + node_index = kwargs.get('node_index') + + # check for redis lock + redis_id = task_id if task_id else secrets.token_hex(8) + lock_name = f"lock:create_report_bg_{redis_id}" + with task_lock(lock_name) as lock_acquired: + + # checking if task is already running + if not lock_acquired: + logger.info('task is already running, skipping execution.') + return None + + # setting defaults + pages = [] + sites = [] + objects = [] + + # get account if account_id exists + if account_id: + account = Account.objects.get(id=account_id) + + # iterating through resources + # and adding to sites or pages + if len(resources) > 0: + for item in resources: + + # adding to pages + if item['type'] == 'page': + try: + pages.append( + Page.objects.get(id=item['id']) + ) + except Exception as e: + print(e) + + # adding to sites + if item['type'] == 'site': + try: + sites.append( + Site.objects.get(id=item['id']) + ) + except Exception as e: + print(e) + + # grabbing all sites because no + # resources were specified and scope is "account" + if len(resources) == 0 and scope == 'account': + sites = Site.objects.filter(account=account) + + # get all pages from existing sites + for site in sites: + pages += Page.objects.filter(site=site) + + # record objects for each report + for page in pages: + + objects.append({ + 'parent': str(page.id), + 'id': None, + 'status': 'working' + }) + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'objects': objects, + 'node_status': 'working', + 'message': f'starting {str(len(objects))} reports for {page.site.site_url} | run_id: {flowrun_id}' + }) + + # create reports for each page + for page in pages: + + # sleeping random for DB + time.sleep(random.uniform(2, 6)) + + create_report.delay( + page_id=page.id, + alert_id=alert_id, + flowrun_id=flowrun_id, + node_index=node_index + ) + + # update schedule if task_id is not None + if task_id: + try: + last_run = datetime.now(timezone.utc) + Schedule.objects.filter(periodic_task_id=task_id).update( + time_last_run=last_run + ) + except Exception as e: + print(e) + + logger.info('Created new Reports') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def create_auto_cases_bg( + self, + site_id: str=None, + process_id: str=None, + start_url: str=None, + max_cases: int=4, + max_layers: int=5, + configs: dict=settings.CONFIGS + ) -> None: + """ + Generates new `Cases` for the passed site. + + Expects: { + site_id : str, + process_id : str, + start_url : str, + max_cases : int, + max_layers : int, + configs : dict + } + + Returns -> None + """ + + # checking location + if not check_location(configs.get('location', settings.LOCATION)): + logger.info('Not running due to location param') + return None + + # get objects + site = Site.objects.get(id=site_id) + process = Process.objects.get(id=process_id) + + # init AutoCaser + AC = AutoCaser( + site=site, + process=process, + start_url=start_url, + configs=configs, + max_cases=max_cases, + max_layers=max_layers, + ) + + # build cases + AC.build_cases() + + logger.info('Built new auto Cases') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def case_pre_run_bg( + self, + case_id: str=None, + process_id: str=None, + ) -> None: + """ + Runs + + Expects: { + case_id : str, + process_id : str, + } + + Returns -> None + """ + + # get objects + case = Case.objects.get(id=case_id) + process = Process.objects.get(id=process_id) + + # init Caser + C = Caser( + case=case, + process=process, + ) + + # build cases + C.pre_run() + + logger.info('Completed Case pre_run') + return None + + + + +@shared_task +def run_case( + caserun_id: str=None, + alert_id: str=None, + flowrun_id: str=None, + node_index: str=None + ) -> None: + """ + Runs a CaseRun. + + Expects: { + caserun_id : str, + alert_id : str, + flowrun_id : str, + node_index : str + } + + Returns -> None + """ + + # get caserun + caserun = CaseRun.objects.get(id=caserun_id) + + # running caserun + Caser( + caserun=caserun, + flowrun_id=flowrun_id, + node_index=node_index + ).run() + + # run alert if requested + if alert_id: + Alerter(alert_id, str(caserun.id)).run_alert() + + logger.info('Ran CaseRun') + return None + + + + +@shared_task +def create_caserun_bg(*args, **kwargs) -> None: + """ + Creates and or runs a CaseRun. + + Expects: { + caserun_id : str, + resources : list, + scope : str, + account_id : str, + case_id : str, + updates : list, + alert_id : str, + configs : dict, + task_id : str, + flowrun_id : str, + node_index : str + } + + Returns -> None + """ + + # get data + caserun_id = kwargs.get('caserun_id') + case_id = kwargs.get('case_id') + account_id = kwargs.get('account_id') + resources = kwargs.get('resources', []) + scope = kwargs.get('scope') + updates = kwargs.get('updates') + alert_id = kwargs.get('alert_id') + task_id = kwargs.get('task_id') + configs = kwargs.get('configs', settings.CONFIGS) + flowrun_id = kwargs.get('flowrun_id') + node_index = kwargs.get('node_index') + + # check for redis lock + redis_id = task_id if task_id else secrets.token_hex(8) + lock_name = f"lock:create_caserun_bg_{redis_id}" + with task_lock(lock_name) as lock_acquired: + + # checking if task is already running + if not lock_acquired: + logger.info('task is already running, skipping execution.') + return None + + # checking location + if not check_location(configs.get('location', settings.LOCATION)): + logger.info('Not running due to location param') + return None + + # settign defaults + case = None + steps = None + caseruns = [] + sites = [] + objects = [] + + # get case + if case_id: + case = Case.objects.get(id=case_id) + + # update steps + if case: + steps = requests.get(case.steps['url']).json() + for step in steps: + if step['action']['type'] != None: + step['action']['time_created'] = None + step['action']['time_completed'] = None + step['action']['exception'] = None + step['action']['status'] = None + + if step['assertion']['type'] != None: + step['assertion']['time_created'] = None + step['assertion']['time_completed'] = None + step['assertion']['exception'] = None + step['assertion']['status'] = None + + # adding updates + if steps: + for update in updates: + steps[int(update['index'])]['action']['value'] = update['value'] + + # getting caserun + if caserun_id: + caseruns = [CaseRun.objects.get(id=caserun_id),] + + # creating caserun from case + if caserun_id is None: + + # getting all sites in resources + for item in resources: + if item['type'] == 'site': + try: + sites.append( + Site.objects.get(id=item['id']) + ) + except Exception as e: + print(e) + + # add all sites in account if scope == 'account' + if scope == 'account' and len(resources) == 0: + sites = Site.objects.filter(account__id=account_id) + + # iterate through sites + for site in sites: + + # check and increment resource + if check_and_increment_resource(site.account, 'caseruns'): + + # create new caserun + caserun = CaseRun.objects.create( + case = case, + title = case.title, + site = site, + user = site.user, + account = site.account, + configs = configs, + steps = steps + ) + + # add to list + caseruns.append(caserun) + + # add to objects + objects.append({ + 'parent': str(site.id), + 'id': str(caserun.id), + 'status': 'working' + }) + + else: + # update flowrun if not able to contiune + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'node_status': 'failed', + 'message': ( + f'❌ case run could not start because this account has reached '+ + f'max_allowed_caseruns for this billing cycle' + ) + }) + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'node_status': 'working', + 'objects': objects + }) + + # iterate through caseruns and run + for caserun in caseruns: + run_case.delay( + caserun_id=str(caserun.id), + alert_id=alert_id, + flowrun_id=flowrun_id, + node_index=node_index + ) + + # update schedule if task_id is not None + if task_id: + try: + last_run = datetime.now(timezone.utc) + Schedule.objects.filter(periodic_task_id=task_id).update( + time_last_run=last_run + ) + except Exception as e: + print(e) + + logger.info('Created CaseRuns') + return None + + + + +@shared_task +def create_flowrun_bg(*args, **kwargs) -> None: + """ + Creates and runs a FlowRun. + + Expects: { + flow_id : str, + resources : list, + scope : str, + account_id : str, + alert_id : str, + configs : dict, + task_id : str + } -) -from .models import Log -from django.contrib.auth.models import User -from .utils.driver_p import driver_test -from asgiref.sync import async_to_sync -import asyncio + Returns -> None + """ + + # get data + flow_id = kwargs.get('flow_id') + account_id = kwargs.get('account_id') + resources = kwargs.get('resources', []) + scope = kwargs.get('scope') + alert_id = kwargs.get('alert_id') + task_id = kwargs.get('task_id') + configs = kwargs.get('configs', settings.CONFIGS) + + # check for redis lock + redis_id = task_id if task_id else secrets.token_hex(8) + lock_name = f"lock:create_flowrun_bg_{redis_id}" + with task_lock(lock_name) as lock_acquired: + + # checking if task is already running + if not lock_acquired: + logger.info('task is already running, skipping execution.') + return None + + # checking location + if not check_location(configs.get('location', settings.LOCATION)): + logger.info('Not running due to location param') + return None + + # settign defaults + flow = None + sites = [] + + # get flow + if flow_id: + flow = Flow.objects.get(id=flow_id) + + # getting all sites in resources + for item in resources: + if item['type'] == 'site': + try: + sites.append( + Site.objects.get(id=item['id']) + ) + except Exception as e: + print(e) + + # add all sites in account if scope == 'account' + if scope == 'account' and len(resources) == 0: + sites = Site.objects.filter(account__id=account_id) + + # iterate through sites + for site in sites: + + # check and increment resource + if check_and_increment_resource(site.account, 'flowruns'): + + # set flowrun_id + flowrun_id = uuid.uuid4() + + # update nodes + _nodes = flow.nodes + for i in range(len(_nodes)): + _nodes[i]['data']['status'] = 'queued' + _nodes[i]['data']['finalized'] = False + _nodes[i]['data']['time_started'] = None + _nodes[i]['data']['time_completed'] = None + _nodes[i]['data']['alert_id'] = alert_id + _nodes[i]['data']['objects'] = [] + + # updates edges + _edges = flow.edges + for i in range(len(_edges)): + _edges[i]['animated'] = False + _edges[i]['style'] = None + + # create init log + logs = [{ + 'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f'), + 'message': f'system starting up for run_id: {str(flowrun_id)}', + 'step': '1' + },] + + # create flowrun + flowrun = FlowRun.objects.create( + id = flowrun_id, + flow = flow, + user = flow.user, + account = flow.account, + site = site, + title = flow.title, + nodes = _nodes, + edges = _edges, + logs = logs, + configs = configs + ) + + # update flow with time_last_run + flow = Flow.objects.get(id=flow_id) + flow.time_last_run = datetime.now(timezone.utc) + flow.save() + + else: + logger.info('max flowruns reached') + + # update schedule if task_id is not None + if task_id: + try: + last_run = datetime.now(timezone.utc) + Schedule.objects.filter(periodic_task_id=task_id).update( + time_last_run=last_run + ) + except Exception as e: + print(e) + + logger.info('Created FlowRuns') + return None + + + + +@shared_task +def delete_site_s3_bg(site_id: str) -> None: + """ + Deletes the directory in s3 bucked associated + with passed site + + Expects: { + 'site_id': str + } + + Returns -> None + """ + + # deleting s3 objects + try: + bucket = s3().Bucket(settings.AWS_STORAGE_BUCKET_NAME) + bucket.objects.filter(Prefix=str(f'static/sites/{site_id}/')).delete() + except: + pass + + logger.info('Deleted site s3 objects') + return None + + + + +@shared_task +def delete_page_s3_bg(page_id: str, site_id: str) -> None: + """ + Deletes the directory in s3 bucked associated + with passed page + + Expects: { + 'site_id': str, + 'page_id': str + } + + Returns -> None + """ + + # deleting s3 objects + try: + bucket = s3().Bucket(settings.AWS_STORAGE_BUCKET_NAME) + bucket.objects.filter(Prefix=str(f'static/sites/{site_id}/{page_id}/')).delete() + except: + pass + + logger.info('Deleted page s3 objects') + return None -logger = get_task_logger(__name__) + + +@shared_task +def delete_scan_s3_bg(scan_id: str, site_id: str, page_id: str) -> None: + """ + Deletes the directory in s3 bucked associated + with passed scan + + Expects: { + 'scan_id': str, + 'site_id': str, + 'page_id': str + } + + Returns -> None + """ + + # deleting s3 objects + try: + bucket = s3().Bucket(settings.AWS_STORAGE_BUCKET_NAME) + bucket.objects.filter(Prefix=str(f'static/sites/{site_id}/{page_id}/{scan_id}/')).delete() + except: + pass + + logger.info('Deleted scan s3 objects') + return None @shared_task -def test_pupeteer(): - asyncio.run(driver_test()) - logger.info('Tested pupeteer instalation') +def delete_test_s3_bg(test_id: str, site_id: str, page_id: str) -> None: + """ + Deletes the directory in s3 bucked associated + with passed test + + Expects: { + 'test_id': str, + 'site_id': str, + 'page_id': str + } + + Returns -> None + """ + + # deleting s3 objects + try: + bucket = s3().Bucket(settings.AWS_STORAGE_BUCKET_NAME) + bucket.objects.filter(Prefix=str(f'static/sites/{site_id}/{page_id}/{test_id}/')).delete() + except: + pass + + logger.info('Deleted test s3 objects') + return None + + @shared_task -def create_site_bg(site_id=None, scan_id=None, configs=None, *args, **kwargs): - create_site_task(site_id, scan_id, configs) - logger.info('Created scan of new site') +def delete_caserun_s3_bg(caserun_id: str) -> None: + """ + Deletes the directory in s3 bucked associated + with passed test + + Expects: { + 'caserun_id': str, + } + + Returns -> None + """ + + # deleting s3 objects + try: + bucket = s3().Bucket(settings.AWS_STORAGE_BUCKET_NAME) + bucket.objects.filter(Prefix=str(f'static/caserun/{caserun_id}/')).delete() + except: + pass + + logger.info('Deleted caserun s3 objects') + return None + + @shared_task -def create_scan_bg( - scan_id=None, - site_id=None, - type=['full'], - automation_id=None, - configs=None, - tags=None, - *args, - **kwargs, - ): - create_scan_task( - scan_id, - site_id, - type, - automation_id, - configs, - tags, +def delete_report_s3_bg(report_id: str) -> None: + """ + Deletes the file in s3 bucked associated + with passed report + + Expects: { + 'report_id': str, + } + + Returns -> None + """ + + # get site + site = Report.objects.get(id=report_id).site + + # deleting s3 objects + try: + bucket = s3().Bucket(settings.AWS_STORAGE_BUCKET_NAME) + bucket.objects.filter(Prefix=str(f'static/sites/{site.id}/{report_id}.pdf')).delete() + except: + pass + + logger.info('Deleted Report pdf in s3') + return None + + + + +@shared_task +def delete_case_s3_bg(case_id: str) -> None: + """ + Deletes the file in s3 bucked associated + with passed case_id + + Expects: { + 'case_id': str, + } + + Returns -> None + """ + + # deleting s3 objects + try: + bucket = s3().Bucket(settings.AWS_STORAGE_BUCKET_NAME) + bucket.objects.filter(Prefix=str(f'static/cases/{case_id}/')).delete() + except: + pass + + logger.info('Deleted Case step data in s3') + return None + + + + +@shared_task +def purge_logs(username: str=None) -> None: + """ + Deletes all `Logs` associated with the passed "username". + If "username" is None, deletes all `Logs`. + + Expects: { + 'username': str + } + + Returns -> None + """ + + # delete logs + if username: + user = User.objects.get(username=username) + Log.objects.filter(user=user).delete() + else: + Log.objects.all().delete() + + logger.info('Purged logs') + return None + + + + +@shared_task +def reset_account_usage(account_id: str=None) -> None: + """ + Loops through each active `Account`, checks to see + if timezone.today() is the start of the + next billing cycle, and resets `Account.usage` + + Expcets: { + 'account_id': (OPTIONAL) + } + + Returns: None + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # check for account_id + if account_id is not None: + accounts = [Account.objects.get(id=account_id)] + else: + # get all active accounts + accounts = Account.objects.filter(active=True) + + # get current date + today = datetime.today() + today_str = today.strftime('%Y-%m-%d') + print(f'today -> {today_str}') + + # setting format for today + f = '%Y-%m-%d %H:%M:%S.%f' + + # reset account.usage + def reset_usage(account) -> None: + # update usage + account.usage['scans'] = 0 + account.usage['tests'] = 0 + account.usage['caseruns'] = 0 + account.usage['flowruns'] = 0 + account.usage['flowruns_allowed'] = 5 # TODO: remove after manual update + # update meta + meta = account.meta + meta['last_usage_reset'] = today.strftime(f) + account.meta = meta + account.save() + return None + + + # loop through each + for account in accounts: + + # check if account is active and not free + if account.active and account.type != 'free' and account.sub_id != None: + + # get stripe sub + sub = stripe.Subscription.retrieve( + account.sub_id + ) + + # get and formate sub.current_period_end + sub_date = datetime.fromtimestamp( + sub.current_period_end + ).strftime('%Y-%m-%d') + print(f'sub_date -> {sub_date}') + + # reset accout usage if today is + # begining of sub payment peroid + # OR if a specific account was requested + if today == sub_date or account_id is not None: + + # reset usage + reset_usage(account) + + # check if accout is free + if account.type == 'free': + + # get last usage reset date from meta + last_usage_date_str = account.meta.get('last_usage_reset') if account.meta else None + if last_usage_date_str is not None: + + # clean date_str + last_usage_date_str = last_usage_date_str.replace('T', ' ').replace('Z', '') + + # format date str as datetime obj + last_usage_date = datetime.strptime(last_usage_date_str, f) + + print(f'days since last reset -> {abs((today - last_usage_date).days)}') + + # check if over 30 days + if abs((today - last_usage_date).days) >= 30: + + # reset usage + reset_usage(account) + + + return None + + + +@shared_task +def temp_account_reset() -> None: + + + for account in Account.objects.all(): + usage = get_usage_default() + usage['sites'] = Site.objects.filter(account=account).count() + account.usage = usage + account.save() + + return None + + + +@shared_task +def update_sub_price(account_id: str=None, sites_allowed: int=None) -> None: + """ + Update price for existing stripe Subscription + based on new `Account.usage.sites_allowed` + + Expects: { + 'account_id' : (REQUIRED) + 'sites_allowed' : (OPTIONAL) + } + + Returns: None + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get account + account = Account.objects.get(id=account_id) + + # set new sites_allowed + if sites_allowed is not None: + account.usage['sites_allowed'] = sites_allowed + account.save() + + # get sites_allowed + if sites_allowed is None: + sites_allowed = account.usage['sites_allowed'] + + # get account coupon + discount = 0 + if account.meta.get('coupon'): + discount = account.meta['coupon']['discount'] + + # calculate + price = ( + ( + 54.444 * (sites_allowed ** 0.4764) + ) * 100 ) - logger.info('Created new scan of site') + + # apply discount + price = price - (price * discount) + + # update for interval + price_amount = round(price if account.interval == 'month' else (price * 10)) + + # create new Stripe Price + price = stripe.Price.create( + product=account.product_id, + unit_amount=price_amount, + currency='usd', + recurring={'interval': account.interval,}, + ) + + # update Stripe Subscription + sub = stripe.Subscription.retrieve(account.sub_id) + stripe.Subscription.modify( + account.sub_id, + cancel_at_period_end=False, + pause_collection='', + proration_behavior='create_prorations', + items=[{ + 'id': sub['items']['data'][0].id, + 'price': price.id, + }], + expand=['latest_invoice.payment_intent'], + ) + + # updating price defaults and archiving old price + stripe.Product.modify(account.product_id, default_price=price,) + stripe.Price.modify(account.price_id, active=False) + + # update account with new info + account.price_id = price.id + account.price_amount = price_amount + account.usage['sites'] = sites_allowed + account.usage['scans_allowed'] = (sites_allowed * 200) + account.usage['tests_allowed'] = (sites_allowed * 200) + account.usage['caseruns_allowed'] = (sites_allowed * 10) + account.usage['flowruns_allowed'] = (sites_allowed * 10) + account.save() + + print(f'new price -> {price_amount}') + + # return + return None @shared_task -def run_html_and_logs_bg(scan_id=None, *args, **kwargs): - run_html_and_logs_task(scan_id) - logger.info('ran html & logs component') +def delete_old_resources(account_id: str=None, days_to_live: int=30) -> None: + """ + Deletes all `Tests`, `Scans`, `CaseRuns`, + `Logs`, and `Processes` that have reached expiry + + Expects: { + account_id : str, + days_to_live : int + } + + Returns -> None + """ + + # calculate max dates + max_date = datetime.now() - timedelta(days=days_to_live) + max_proc_date = datetime.now() - timedelta(days=1) + + # scope resources to account if requested + if account_id is not None: + tests = Test.objects.filter(site__account__id=account_id, time_created__lte=max_date) + scans = Scan.objects.filter(site__account__id=account_id, time_created__lte=max_date) + caseruns = CaseRun.objects.filter(account__id=account_id, time_created__lte=max_date) + flowruns = FlowRun.objects.filter(account__id=account_id, time_created__lte=max_date) + processes = Process.objects.filter(account__id=account_id, time_created__lte=max_proc_date) + + # get all old Logs + members = Member.objects.filter(account__id=account_id) + logs = [] + for member in members: + logs += Log.objects.filter(user=member.user, time_created__lte=max_proc_date) + + # get all resoruces if no account_id + else: + tests = Test.objects.filter(time_created__lte=max_date) + scans = Scan.objects.filter(time_created__lte=max_date) + caseruns = CaseRun.objects.filter(time_created__lte=max_date) + flowruns = FlowRun.objects.filter(time_created__lte=max_date) + processes = Process.objects.filter(time_created__lte=max_proc_date) + logs = Log.objects.filter(time_created__lte=max_proc_date) + + # delete each resource in each type + for test in tests: + delete_test_s3_bg.delay(test.id, test.site.id, test.page.id) + test.delete() + for scan in scans: + delete_scan_s3_bg.delay(scan.id, scan.site.id, scan.page.id) + scan.delete() + for caserun in caseruns: + delete_caserun_s3_bg.delay(caserun.id) + caserun.delete() + for flowrun in flowruns: + flowrun.delete() + for process in processes: + process.delete() + for log in logs: + log.delete() + + logger.info('Cleaned up resources') + return None + + @shared_task -def run_vrt_bg(scan_id=None, *args, **kwargs): - run_vrt_task(scan_id) - logger.info('ran vrt component') +def data_retention() -> None: + """ + Helper task for looping through each account and deleting old resources using + delete_old_resources() + + Returns -> None + """ + + # get all accounts + accounts = Account.objects.all() + + # loop through each account + for account in accounts: + + # delete old resources + delete_old_resources.delay( + account_id=account.id, + days_to_live=account.usage['retention_days'] + ) + + logger.info('Requested resource cleanup') + return None + + @shared_task -def run_lighthouse_bg(scan_id=None, *args, **kwargs): - run_lighthouse_task(scan_id) - logger.info('ran lighthouse component') +def delete_admin_sites(days_to_live: int=1) -> None: + """ + Delete all admin sites which are older + than 'days_to_live' + + Expects: { + 'days_to_live': int + } + + Returns -> None + """ + + # calculate max date + max_date = datetime.now() - timedelta(days=days_to_live) + + # filter sites by max_date and admin + sites = Site.objects.filter(time_created__lte=max_date, user__username='admin') + + # delete each site + for site in sites: + delete_site_s3_bg.delay(site.id) + site.delete() + + logger.info('Cleaned up admin sites') + return None + + @shared_task -def run_yellowlab_bg(scan_id=None, *args, **kwargs): - run_yellowlab_task(scan_id) - logger.info('ran yellowlab component') +def create_prospect(user_email: str=None) -> None: + """ + Sends an API request to Cursion Landing which + creates a new `Prospect` + + Expects: { + 'user_email': str + } + + Returns -> None + """ + + if settings.MODE == 'selfhost': + print('not running because of selfhost mode') + return None + + # get user by id + user = User.objects.get(email=user_email) + phone = None + if Member.objects.filter(user=user).exists(): + member = Member.objects.get(user=user) + phone = member.phone + + # get account by user + account = Account.objects.get(user=user) + + # determinig user's 'status' + if account.type == 'free': + if Site.objects.filter(account=account).exists(): + _status = 'warm' # account has one site onboarded + else: + _status = 'cold' # account is free but no site onboarded + if account.type != 'free': + if account.active: + _status = 'customer' # account is active and paid + else: + _status = 'warm' # account is paused and paid + if account.type == 'new': + _status = 'cold' # account has not onboarded + if account.type == 'selfhost': + _status = 'customer' # account is active and paid + + # setup configs + url = f'{settings.LANDING_URL_ROOT}/ops/prospect' + headers = { + "content-type": "application/json", + "Authorization" : f'Token {settings.LANDING_API_KEY}' + } + data = { + 'first_name': str(user.first_name), + 'last_name': str(user.last_name), + 'email': str(user.email), + 'phone': phone, + 'license_key': str(account.license_key), + 'info': account.info, + 'status': _status, + 'source': 'app', + } + + try: + # send the request + res = requests.post( + url=url, + headers=headers, + data=json.dumps(data) + ).json() + + success = True + message = res + + except Exception as e: + success = False + message = e + + # format response + data = { + 'success': success, + 'message': message + } + + logger.info(f'Sent Prospect creation request -> {data}') + return None @shared_task -def create_test_bg( - test_id=None, - site_id=None, - automation_id=None, - configs=None, - type=['full'], - index=None, - pre_scan=None, - post_scan=None, - tags=None, - *args, - **kwargs, - ): - create_test_task( - test_id, - site_id, - automation_id, - configs, - type, - index, - pre_scan, - post_scan, - tags +def create_report_export_bg(report_id: str=None, email: str=None, first_name: str=None) -> None: + """ + Creates and exports a Cursion landing report + + Expects: { + report_id : str, + email : str, + first_name : str + } + + Returns -> None + """ + + # create and export + data = create_and_send_report_export( + report_id=report_id, + email=email, + first_name=first_name ) - logger.info('Created new test of site') + + logger.info(f'Created and sent report export -> {data}') + return None + + + @shared_task -def create_report_bg(site_id=None, automation_id=None, *args, **kwargs): - create_report_task(site_id, automation_id) - logger.info('Created new report of site') +def send_invite_link_bg(member_id: str) -> None: + """ + Sends an invite link to the requested member + + Expects: { + 'member_id': str + } + + Returns -> None + """ + + # get member + member = Member.objects.get(id=member_id) + + # send invite + send_invite_link(member) + + logger.info('Sent invite') + return None + + @shared_task -def delete_site_s3_bg(site_id, *args, **kwargs): - delete_site_s3(site_id) - logger.info('Deleted site s3 objects') +def send_remove_alert_bg(member_id: str) -> None: + """ + Sends a 'removed' email to the requested member + + Expects: { + 'member_id': str + } + + Returns -> None + """ + + # get member + member = Member.objects.get(id=member_id) + + # send email + send_remove_alert(member) + + logger.info('Sent remove alert') + return None + + @shared_task -def delete_testcase_s3_bg(testcase_id, *args, **kwargs): - delete_testcase_s3(testcase_id) - logger.info('Deleted testcase s3 objects') +def send_phone_bg( + account_id: str=None, + objects: list=None, + phone_number: str=None, + body: str=None, + flowrun_id: str=None, + node_index: str=None + ) -> dict: + """ + Run `Alerts.send_phone` as a backgroud task + + Expects: { + 'account_id' : str, + 'objects' : str, + 'phone_number' : str, + 'body' : str, + 'flowrun_id' : str, + 'node_index' : str, + } + + Returns: None + """ + + # interating through objects + for obj in objects: + + # sleeping random for DB + time.sleep(random.uniform(2, 6)) + + # run send_phone + resp = send_phone( + account_id=account_id, + object_id=obj['id'], + phone_number=phone_number, + body=body, + ) + + if flowrun_id and flowrun_id != 'None': + # update flowrun + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': resp.get('message'), + 'objects': [{ + 'parent': obj['parent'], + 'id': obj['id'], + 'status': 'passed' if resp.get('success') else 'failed' + }] + }) + + logger.info('sent phone message') + return None + + @shared_task -def delete_report_s3_bg(report_id, *args, **kwargs): - delete_report_s3(report_id) - logger.info('Deleted Report pdf in s3') +def send_slack_bg( + account_id: str=None, + objects: list=None, + body: str=None, + flowrun_id: str=None, + node_index: str=None + ) -> dict: + """ + Run `Alerts.send_slack` as a backgroud task + + Expects: { + 'account_id' : str, + 'objects' : list, + 'body' : str, + 'flowrun_id' : str, + 'node_index' : str, + } + + Returns: None + """ + + # interating through objects + for obj in objects: + + # sleeping random for DB + time.sleep(random.uniform(2, 6)) + + # run send_slack + resp = send_slack( + account_id=account_id, + object_id=obj['id'], + body=body, + ) + + if flowrun_id and flowrun_id != 'None': + # update flowrun + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': resp.get('message'), + 'objects': [{ + 'parent': obj['parent'], + 'id': obj['id'], + 'status': 'passed' if resp.get('success') else 'failed' + }] + }) + + logger.info('sent slack message') + return None + + @shared_task -def purge_logs(username=None, *args, **kwargs): - if username: - user = User.objects.get(username=username) - Log.objects.filter(user=user).delete() - else: - Log.objects.all().delete() +def send_email_bg( + account_id: str=None, + objects: list=None, + message_obj: dict=None, + flowrun_id: str=None, + node_index: str=None + ) -> dict: + """ + Run `Alerts.sendgrid_email` as a backgroud task + + Expects: { + 'account_id' : str, + 'objects' : list, + 'message_obj' : dict, + 'flowrun_id' : str, + 'node_index' : str, + } + + Returns: None + """ + + # interating through objects + for obj in objects: + + # sleeping random for DB + time.sleep(random.uniform(2, 6)) + + # run sendgrid_email + resp = sendgrid_email( + account_id=account_id, + object_id=obj['id'], + message_obj=message_obj, + ) + + if flowrun_id and flowrun_id != 'None': + # update flowrun + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': resp.get('message'), + 'objects': [{ + 'parent': obj['parent'], + 'id': obj['id'], + 'status': 'passed' if resp.get('success') else 'failed' + }] + }) + + logger.info('sent email message') + return None + - logger.info('Purged logs') @shared_task -def create_testcase_bg( - testcase_id=None, - site_id=None, - case_id=None, - updates=None, - automation_id=None, - configs=None, - type=None, - *args, - **kwargs, - ): - create_testcase_task(testcase_id, site_id, case_id, updates, configs, automation_id) - logger.info('Ran full testcase') +def send_webhook_bg( + account_id: str=None, + objects: list=None, + request_type: str=None, + url: str=None, + headers: str=None, + payload: str=None, + flowrun_id: str=None, + node_index: str=None + ) -> dict: + """ + Run `Alerts.sendgrid_email` as a backgroud task + + Expects: { + 'account_id' : str, + 'objects' : list, + 'request_type' : str, + 'url' : str, + 'headers' : str, + 'payload' : str, + 'flowrun_id' : str, + 'node_index' : str, + } + + Returns: None + """ + + # interating through objects + for obj in objects: + + # sleeping random for DB + time.sleep(random.uniform(2, 6)) + + # run sendgrid_email + resp = send_webhook( + account_id=account_id, + object_id=obj['id'], + request_type=request_type, + url=url, + headers=headers, + payload=payload + ) + + if flowrun_id and flowrun_id != 'None': + # update flowrun + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': resp.get('message'), + 'objects': [{ + 'parent': obj['parent'], + 'id': obj['id'], + 'status': 'passed' if resp.get('success') else 'failed' + }] + }) + + logger.info('sent webhook message') + return None @shared_task def migrate_site_bg( - login_url, - admin_url, - username, - password, - email_address, - destination_url, - sftp_address, - dbname, - sftp_username, - sftp_password, - plugin_name, - wait_time, - process_id, - driver, - *args, - **kwargs - ): - migrate_site_task( - login_url, - admin_url, - username, - password, - email_address, - destination_url, - sftp_address, - dbname, - sftp_username, - sftp_password, - plugin_name, - wait_time, - process_id, - driver, + login_url: str, + admin_url: str, + username: str, + password: str, + email_address: str, + destination_url: str, + sftp_address: str, + dbname: str, + sftp_username: str, + sftp_password: str, + plugin_name: str, + wait_time: int, + process_id: str, + driver: str, + ) -> None: + """ + Runs the WP site migration process. + + Expects: { + login_url: str, + admin_url: str, + username: str, + password: str, + email_address: str, + destination_url: str, + sftp_address: str, + dbname: str, + sftp_username: str, + sftp_password: str, + plugin_name: str, + wait_time: int, + process_id: str, + driver: str, + } + + Returns -> None + """ + + # init wordpress for selenium + wp = W( + login_url=login_url, + admin_url=admin_url, + username=username, + password=password, + email_address=email_address, + destination_url=destination_url, + sftp_address=sftp_address, + dbname=dbname, + sftp_username=sftp_username, + sftp_password=sftp_password, + wait_time=wait_time, + process_id=process_id, + ) - logger.info('Finished Migration') \ No newline at end of file + # login + wp_status = wp.login() + # adjust lang + wp_status = wp.begin_lang_check() + # install plugin + wp_status = wp.install_plugin(plugin_name=plugin_name) + # launch migration + wp_status = wp.launch_migration() + # run migration + wp_status = wp.run_migration() + # re adjust lang + # wp_status = wp.end_lang_check() + + + logger.info('Finished Migration') + return None + + + diff --git a/app/api/templates/api/alert_no_button.html b/app/api/templates/api/alert_no_button.html index 20e8260e..6dbc4813 100644 --- a/app/api/templates/api/alert_no_button.html +++ b/app/api/templates/api/alert_no_button.html @@ -137,12 +137,12 @@
- Scanerr, San Antonio TX + Cursion, San Antonio TX
- Powered by Scanerr. + Powered by Cursion.
diff --git a/app/api/templates/api/alert_with_button.html b/app/api/templates/api/alert_with_button.html index 13c30107..2b163e4d 100644 --- a/app/api/templates/api/alert_with_button.html +++ b/app/api/templates/api/alert_with_button.html @@ -144,12 +144,12 @@
- Scanerr, San Antonio TX + Cursion, San Antonio TX
- Powered by Scanerr. + Powered by Cursion.
diff --git a/app/api/templates/api/automation_email.html b/app/api/templates/api/automation_email.html index 6ba3c45f..30719264 100644 --- a/app/api/templates/api/automation_email.html +++ b/app/api/templates/api/automation_email.html @@ -152,12 +152,12 @@
- Scanerr, San Antonio TX + Cursion, San Antonio TX
- Powered by Scanerr. + Powered by Cursion.
diff --git a/app/api/tests.py b/app/api/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/app/api/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/app/api/urls.py b/app/api/urls.py index 9df63e49..39d9edea 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -1,11 +1,13 @@ from .v1 import urls as v1_urls from django.urls import path, include +from django.views.generic.base import RedirectView urlpatterns = [ path('v1/', include(v1_urls)), + path('', RedirectView.as_view(url='v1/auth/', permanent=False)) ] diff --git a/app/api/utils/alerter.py b/app/api/utils/alerter.py new file mode 100644 index 00000000..00021d4a --- /dev/null +++ b/app/api/utils/alerter.py @@ -0,0 +1,258 @@ +from ..models import * +from .alerts import * +import re, uuid + + + + + + +class Alerter(): + """ + Build and execute `Alert` logic generated by a user. + + Expects: { + 'alert_id' : str, + 'object_id' : str, + 'expressions' : list + } + + - Use `Alerter.run_alert()` to run an `Alert` + - Use `Alerter.get_object()` to set self.object + - Use `Alerter.build_expressions()` to get self.exp_string + + Returns -> None + """ + + + def __init__( + self, + alert_id: str=None, + object_id: str=None, + expressions: list=[], + task_type: str=None + ): + + self.alert = Alert.objects.get(id=alert_id) if alert_id else None + self.expressions = self.alert.expressions if self.alert else expressions + self.task_type = self.alert.schedule.task_type if self.alert else task_type + self.object_id = object_id + self.exp_string = '1 == 1' + self.act_string = '' + self.object = None + self.use_exp = True + + + + + def get_object(self) -> bool: + """ + Tries to get the focus object from self.object_id - if found + will set self.object and self.use_exp + + Returns -> None or object + """ + + if self.task_type == 'scan': + try: + self.object = Scan.objects.get(id=self.object_id) + self.use_exp = True + return self.object + except: + return None + + elif self.task_type == 'test': + try: + self.object = Test.objects.get(id=self.object_id) + self.use_exp = True + return self.object + except: + return None + + elif self.task_type == 'report': + try: + self.object = Report.objects.get(id=self.object_id) + self.use_exp = False + return self.object + except: + return None + + elif self.task_type == 'caserun' or self.task_type == 'case': + try: + self.object = CaseRun.objects.get(id=self.object_id) + self.use_exp = True + return self.object + except: + return None + + elif self.task_type == 'flowrun' or self.task_type == 'flow': + try: + self.object = FlowRun.objects.get(id=self.object_id) + self.use_exp = True + return self.object + except: + return None + + else: + return None + + + + + def build_expressions(self) -> None: + """ + Loop through the self.expressions + and rebuilds into self.exp_string + + Returns -> self.exp_string + """ + + # begin iteration + exp_list = [] + for expression in self.expressions: + + # set defaults + exp = None + data_type = None + operator = ' == ' + joiner = '' + data_type = 'obj.status' + value = f"str('{str(expression['value'])}')" + non_float_types = ['caserun_status', 'test_status', 'flowrun_status'] + + # get comparison value + if expression['data_type'] not in non_float_types: + value = str(float(re.search(r'\d+', str(expression['value'])).group())) + + # get operator + if '>=' in expression['operator']: + operator = ' >= ' + elif '<=' in expression['operator']: + operator = ' <= ' + else: + operator = ' == ' + + # get joiner + if 'and' in expression['joiner']: + joiner = ' and ' + elif 'or' in expression['joiner']: + joiner = ' or ' + else: + joiner = '' + + # get data_type translation + definition = get_definition(expression['data_type']) + if definition: + raw_value = definition['value'] + data_type = f'({raw_value} if {raw_value} else 0)' + + # building exp if not defined + if exp is None: + exp = f'{joiner}{data_type}{operator}{value}' + + # adding exp to exp_list + exp_list.append(exp) + + # build expression string + self.exp_string = ' '.join(exp_list) + + # return exp_string + return self.exp_string + + + + + def build_actions(self) -> None: + """ + Loop through the alert.actions + and rebuilds into self.act_string + + Returns -> self.act_string + """ + + # defaults + act_list = [] + + # begin iteration + for action in self.alert.actions: + + if 'slack' in action['action_type']: + action_type = str( + f"\n print('sending slack alert')" + + f"\n alert_slack(alert_id='{str(self.alert.id)}'," + + f" object_id='{str(self.object_id)}')" + ) + + if 'email' in action['action_type']: + action_type = str( + f"\n print('sending email alert')" + + f"\n alert_email(email='{action['email']}'," + + f" alert_id='{str(self.alert.id)}'," + + f" object_id='{str(self.object_id)}')" + ) + + if type(self.object).__name__ == 'Report': + action_type = str( + f"\n print('sending report email')" + + f"\n alert_report_email(email='{action['email']}'," + + f" alert_id='{str(self.alert.id)}'," + + f" object_id='{str(self.object_id)}')" + ) + + if 'phone' in action['action_type']: + action_type = str( + f"\n print('sending phone alert')" + + f"\n alert_phone(phone_number='{action['phone']}'," + + f" alert_id='{str(self.alert.id)}'," + + f" object_id='{str(self.object_id)}')" + ) + + # adding action to act_list + act_list.append(action_type) + + # build string + self.act_string = ''.join(act_list) + + # return formated actions string + return self.act_string + + + + + def run_alert(self) -> None: + + # get object data + self.get_object() + + # if obj was retrieved + if self.object: + + # setting obj for defnitions data + obj = self.object + + # build expressions + if self.use_exp: + self.build_expressions() + + # build actions + self.build_actions() + + # building final exec str + alert_logic = f'if {self.exp_string}:{self.act_string}' + + # executing alert logic + exec(alert_logic) + + return None + + + + + + + + + + + + \ No newline at end of file diff --git a/app/api/utils/alerts.py b/app/api/utils/alerts.py index fb4f68df..c90d2e71 100644 --- a/app/api/utils/alerts.py +++ b/app/api/utils/alerts.py @@ -1,326 +1,187 @@ -from django.core.mail import send_mail, send_mass_mail from django.contrib.auth.models import User -from django.template.loader import render_to_string -from datetime import date -import os, operator, json, requests, uuid -from django.utils.html import strip_tags -from django.contrib.auth.models import User -from rest_framework.response import Response -from ..models import * +from rest_framework_simplejwt.tokens import RefreshToken from twilio.rest import Client from slack_sdk.web import WebClient from slack_sdk.errors import SlackApiError from sendgrid import SendGridAPIClient -from sendgrid.helpers.mail import Mail, From, To -from scanerr import settings +from sendgrid.helpers.mail import * +from ..models import * +from cursion import settings +from .definitions import get_definition, definitions +from datetime import date +from cryptography.fernet import Fernet +import os, json, requests, uuid -def create_exp_str(item, automation, is_email=False): - exp_list = [] - - for e in automation.expressions: - if 'test_score' in e['data_type']: - data_type = 'Test Score:\t'+str(round(item.score, 2))+'\n\t' - elif 'current_health' in e['data_type']: - data_type = 'Health:\t'+str((float(item.lighthouse_delta["scores"]["current_average"]) + float(item.yellowlab_delta["scores"]["current_average"])/2))+'\n\t' - elif 'health' in e['data_type']: - data_type = 'Health:\t'+str((float(item.lighthouse["scores"]["average"]) + float(item.yellowlab["scores"]["globalScore"])/2))+'\n\t' - # LH test data - elif 'current_lighthouse_average' in e['data_type']: - data_type = 'Lighthouse Average:\t'+str(item.lighthouse_delta["scores"]["current_average"])+'\n\t' - elif 'seo_delta' in e['data_type']: - data_type = 'SEO Delta:\t'+str(item.lighthouse_delta["scores"]["seo_delta"])+'\n\t' - elif 'pwa_delta' in e['data_type']: - data_type = 'PWA Delta:\t'+str(item.lighthouse_delta["scores"]["pwa_delta"])+'\n\t' - elif 'crux_delta' in e['data_type']: - data_type = 'CRUX Delta:\t'+str(item.lighthouse_delta["scores"]["crux_delta"])+'\n\t' - elif 'best_practices_delta' in e['data_type']: - data_type = 'Best Practices Delta:\t'+str(item.lighthouse_delta["scores"]["best_practices_delta"])+'\n\t' - elif 'performance_delta' in e['data_type']: - data_type = 'Performance Delta:\t'+str(item.lighthouse_delta["scores"]["performance_delta"])+'\n\t' - elif 'accessibility_delta' in e['data_type']: - data_type = 'Accessibility Delta:\t'+str(item.lighthouse_delta["scores"]["accessibility_delta"])+'\n\t' - # LH scan data - elif 'lighthouse_average' in e['data_type']: - data_type = 'Lighthouse Average:\t'+str(item.lighthouse["scores"]["average"])+'\n\t' - elif 'seo' in e['data_type']: - data_type = 'SEO:\t'+str(item.lighthouse["scores"]["seo"])+'\n\t' - elif 'pwa' in e['data_type']: - data_type = 'PWA:\t'+str(item.lighthouse["scores"]["pwa"])+'\n\t' - elif 'crux' in e['data_type']: - data_type = 'CRUX:\t'+str(item.lighthouse["scores"]["crux"])+'\n\t' - elif 'best_practices' in e['data_type']: - data_type = 'Best Practices:\t'+str(item.lighthouse["scores"]["best_practices"])+'\n\t' - elif 'performance' in e['data_type']: - data_type = 'Performance:\t'+str(item.lighthouse["scores"]["performance"])+'\n\t' - elif 'accessibility' in e['data_type']: - data_type = 'Accessibility:\t'+str(item.lighthouse["scores"]["accessibility"])+'\n\t' - - +def send_reset_link(email: str=None) -> dict: + """ + Sends a reset password email to the User with + the passed 'email' - # yellowlab test data - elif 'current_yellowlab_average' in e['data_type']: - data_type = 'Yellow Lab Avg:\t'+str(item.yellowlab_delta["scores"]["current_average"])+'\n\t' - elif 'pageWeight_delta' in e['data_type']: - data_type = 'Page Weight Delta:\t'+str(item.yellowlab_delta["scores"]["pageWeight_delta"])+'\n\t' - elif 'requests_delta' in e['data_type']: - data_type = 'Requests Delta:\t'+str(item.yellowlab_delta["scores"]["requests_delta"])+'\n\t' - elif 'domComplexity_delta' in e['data_type']: - data_type = 'DOM Complex. Delta:\t'+str(item.yellowlab_delta["scores"]["domComplexity_delta"])+'\n\t' - elif 'javascriptComplexity_delta' in e['data_type']: - data_type = 'JS Complex. Delta:\t'+str(item.yellowlab_delta["scores"]["javascriptComplexity_delta"])+'\n\t' - elif 'badJavascript_delta' in e['data_type']: - data_type = 'Bad JS Delta:\t'+str(item.yellowlab_delta["scores"]["badJavascript_delta"])+'\n\t' - elif 'jQuery_delta' in e['data_type']: - data_type = 'jQuery Delta:\t'+str(item.yellowlab_delta["scores"]["jQuery_delta"])+'\n\t' - elif 'cssComplexity_delta' in e['data_type']: - data_type = 'CSS Complex. Delta:\t'+str(item.yellowlab_delta["scores"]["cssComplexity_delta"])+'\n\t' - elif 'badCSS_delta' in e['data_type']: - data_type = 'Bad CSS Delta:\t'+str(item.yellowlab_delta["scores"]["badCSS_delta"])+'\n\t' - elif 'fonts_delta' in e['data_type']: - data_type = 'Fonts Delta:\t'+str(item.yellowlab_delta["scores"]["fonts_delta"])+'\n\t' - elif 'serverConfig_delta' in e['data_type']: - data_type = 'Server Config Delta:\t'+str(item.yellowlab_delta["scores"]["serverConfig_delta"])+'\n\t' - - # yellowlab scan data - elif 'yellowlab_average' in e['data_type']: - data_type = 'Yellow Lab Avg:\t'+str(item.yellowlab["scores"]["globalScore"])+'\n\t' - elif 'pageWeight' in e['data_type']: - data_type = 'Page Weight:\t'+str(item.yellowlab["scores"]["pageWeight"])+'\n\t' - elif 'requests' in e['data_type']: - data_type = 'Requests:\t'+str(item.yellowlab["scores"]["requests"])+'\n\t' - elif 'domComplexity' in e['data_type']: - data_type = 'DOM Complex.:\t'+str(item.yellowlab["scores"]["domComplexity"])+'\n\t' - elif 'javascriptComplexity' in e['data_type']: - data_type = 'JS Complex.:\t'+str(item.yellowlab["scores"]["javascriptComplexity"])+'\n\t' - elif 'badJavascript' in e['data_type']: - data_type = 'Bad JS:\t'+str(item.yellowlab["scores"]["badJavascript"])+'\n\t' - elif 'jQuery' in e['data_type']: - data_type = 'jQuery:\t'+str(item.yellowlab["scores"]["jQuery"])+'\n\t' - elif 'cssComplexity' in e['data_type']: - data_type = 'CSS Complex.:\t'+str(item.yellowlab["scores"]["cssComplexity"])+'\n\t' - elif 'badCSS' in e['data_type']: - data_type = 'Bad CSS:\t'+str(item.yellowlab["scores"]["badCSS"])+'\n\t' - elif 'fonts' in e['data_type']: - data_type = 'Fonts:\t'+str(item.yellowlab["scores"]["fonts"])+'\n\t' - elif 'serverConfig' in e['data_type']: - data_type = 'Server Config:\t'+str(item.yellowlab["scores"]["serverConfig"])+'\n\t' - - elif 'avg_image_score' in e['data_type']: - data_type = ' Avg Image Score:\t'+str(item.images_delta["average_score"])+'\n\t' - elif 'image_scores' in e['data_type']: - data_type = 'List of Image Scores:\t'+str([i["score"] for i in item.images_delta["images"]])+'\n\t' - - elif 'logs' in e['data_type']: - data_type = 'Error Logs:\t'+str(len(item.logs))+'\n\t' + Expects: { + 'email': str + } + Returns -> data: { + 'success': bool + } + """ - elif 'testcase' in e['data_type']: - status = 'Failed' - if e['value'] == 'True': - status = 'Passed' - data_type = 'Testcase "'+str(item.case.name)+'" --> '+str(status) - - - exp_list.append(data_type) - - if is_email: - return exp_list - - exp_str = ('\t'+''.join(exp_list)) - return exp_str - - - - - -def create_json_data(data, obj): - json_data = data - item = obj - - for key in json_data: - if 'test_score' == json_data[key]: - json_data[key] = item.score - elif 'seo_delta' == json_data[key]: - json_data[key] = item.lighthouse_delta["scores"]["seo_delta"] - elif 'pwa_delta' == json_data[key]: - json_data[key] = item.lighthouse_delta["scores"]["pwa_delta"] - elif 'crux_delta' == json_data[key]: - json_data[key] = item.lighthouse_delta["scores"]["crux_delta"] - elif 'best_practices_delta' == json_data[key]: - json_data[key] = item.lighthouse_delta["scores"]["best_practices_delta"] - elif 'performance_delta' == json_data[key]: - json_data[key] = item.lighthouse_delta["scores"]["performance_delta"] - elif 'accessibility_delta' == json_data[key]: - json_data[key] = item.lighthouse_delta["scores"]["accessibility_delta"] - elif 'current_health' == json_data[key]: - json_data[key] = (float(item.lighthouse_delta["scores"]["average"]) + float(item.yellowlab_delta["scores"]["globalScore"])/2) - elif 'health' == json_data[key]: - json_data[key] = (float(item.lighthouse["scores"]["average"]) + float(item.yellowlab["scores"]["globalScore"])/2) - elif 'logs' == json_data[key]: - json_data[key] = len(item.logs) - elif 'current_lighthouse_average' == json_data[key]: - json_data[key] = item.lighthouse_delta["scores"]["current_average"] - elif 'current_average' == json_data[key]: - json_data[key] = item.lighthouse["scores"]["current_average"] - elif 'seo' == json_data[key]: - json_data[key] = item.lighthouse["scores"]["seo"] - elif 'pwa' == json_data[key]: - json_data[key] = item.lighthouse["scores"]["pwa"] - elif 'crux' == json_data[key]: - json_data[key] = item.lighthouse["scores"]["crux"] - elif 'best_practice' == json_data[key]: - json_data[key] = item.lighthouse["scores"]["best_practices"] - elif 'performance' == json_data[key]: - json_data[key] = item.lighthouse["scores"]["performance"] - elif 'accessibility' == json_data[key]: - json_data[key] = item.lighthouse["scores"]["accessibility"] - - elif 'current_yellowlab_average' == json_data[key]: - json_data[key] = item.yellowlab_delta["scores"]["current_average"] - elif 'pageWeight_delta' == json_data[key]: - json_data[key] = item.yellowlab_delta["scores"]["pageWeight_delta"] - elif 'requests_delta' == json_data[key]: - json_data[key] = item.yellowlab_delta["scores"]["requests_delta"] - elif 'domComplexity_delta' == json_data[key]: - json_data[key] = item.yellowlab_delta["scores"]["domComplexity_delta"] - elif 'javascriptComplexity_delta' == json_data[key]: - json_data[key] = item.yellowlab_delta["scores"]["javascriptComplexity_delta"] - elif 'badJavascript_delta' == json_data[key]: - json_data[key] = item.yellowlab_delta["scores"]["badJavascript_delta"] - elif 'jQuery_delta' == json_data[key]: - json_data[key] = item.yellowlab_delta["scores"]["jQuery_delta"] - elif 'cssComplexity_delta' == json_data[key]: - json_data[key] = item.yellowlab_delta["scores"]["cssComplexity_delta"] - elif 'badCSS_delta' == json_data[key]: - json_data[key] = item.yellowlab_delta["scores"]["badCSS_delta"] - elif 'fonts_delta' == json_data[key]: - json_data[key] = item.yellowlab_delta["scores"]["fonts_delta"] - elif 'serverConfig_delta' == json_data[key]: - json_data[key] = item.yellowlab_delta["scores"]["serverConfig_delta"] - - elif 'yellowlab_average' == json_data[key]: - json_data[key] = item.yellowlab["scores"]["globalScore"] - elif 'pageWeight' == json_data[key]: - json_data[key] = item.yellowlab["scores"]["pageWeight"] - elif 'requests' == json_data[key]: - json_data[key] = item.yellowlab["scores"]["requests"] - elif 'domComplexity' == json_data[key]: - json_data[key] = item.yellowlab["scores"]["domComplexity"] - elif 'javascriptComplexity' == json_data[key]: - json_data[key] = item.yellowlab["scores"]["javascriptComplexity"] - elif 'badJavascript' == json_data[key]: - json_data[key] = item.yellowlab["scores"]["badJavascript"] - elif 'jQuery' == json_data[key]: - json_data[key] = item.yellowlab["scores"]["jQuery"] - elif 'cssComplexity' == json_data[key]: - json_data[key] = item.yellowlab["scores"]["cssComplexity"] - elif 'badCSS' == json_data[key]: - json_data[key] = item.yellowlab["scores"]["badCSS"] - elif 'fonts' == json_data[key]: - json_data[key] = item.yellowlab["scores"]["fonts"] - elif 'serverConfig' == json_data[key]: - json_data[key] = item.yellowlab["scores"]["serverConfig"] + # check if User exists + if User.objects.filter(email=email).exists(): - elif 'avg_image_score' == json_data[key]: - json_data[key] = item.images_delta["average_score"] - elif 'image_scores' == json_data[key]: - json_data[key] = [i["score"] for i in item.images_delta["images"]] + # build email data + user = User.objects.get(email=email) + token = RefreshToken.for_user(user) + access_token = str(token.access_token) + reset_link = str(settings.CLIENT_URL_ROOT+'/reset-password?token='+access_token) + subject = 'Rest Password' + title = 'Reset Password' + pre_header = 'Reset Password' + pre_content = 'Click the link below to reset your password.' + greeting = f'Hi there,' + context = { + 'greeting': greeting, + 'title' : title, + 'subject' : subject, + 'email': email, + 'pre_header' : pre_header, + 'pre_content' : pre_content, + 'object_url' : reset_link, + 'home_page' : settings.CLIENT_URL_ROOT, + 'button_text' : 'Rest my password', + 'content' : '', + 'signature' : '- Cheers!', + } - return json_data + # send email + sendgrid_email(message_obj=context) + data = { + 'success': True + } + + else: + data = { + 'success': False + } + + return data +def send_invite_link(member: object=None) -> dict: + """ + Sends an invite email to the passed `Member` -def get_item(object_id): - try: - item = Test.objects.get(id=uuid.UUID(object_id)) - item_type = 'Test' - except: - try: - item = Scan.objects.get(id=uuid.UUID(object_id)) - item_type = 'Scan' - except: - try: - item = Testcase.objects.get(id=uuid.UUID(object_id)) - item_type = 'Testcase' - except: - return {'success': False} + Expects: { + 'member': obj + } - data = { - 'item_type': item_type, - 'item': item, - 'success': True + Returns -> data: { + 'success': bool } + """ - return data + # check if member exists as status "pending" + if Member.objects.filter(email=member.email, status="pending").exists(): + + # build email data + link = ( + f'{settings.CLIENT_URL_ROOT}/account/join?team={member.account.id}'+ + f'&code={member.account.code}&member={member.id}&email={member.email}' + ) + subject = 'Cursion Invite' + title = 'Cursion Invite' + pre_header = 'Cursion Invite' + pre_content = ( + f'A user with the email "{member.account.user.username}" invited you to join their '+ + f'Team on Cursion. Now just click the link below to accept the invite!' + ) + greeting = 'Hi there,' + + context = { + 'greeting': greeting, + 'title' : title, + 'subject' : subject, + 'email': member.email, + 'pre_header' : pre_header, + 'pre_content' : pre_content, + 'object_url' : link, + 'home_page' : settings.CLIENT_URL_ROOT, + 'button_text' : 'Accept Invite', + 'content' : '', + 'signature' : '- Cheers!', + } + + # send email + sendgrid_email(message_obj=context) + data = { + 'success': True + } + + else: + data = { + 'success': False + } + + return data -def automation_email(email=None, automation_id=None, object_id=None): - if email and automation_id: - automation = Automation.objects.get(id=automation_id) - schedule = automation.schedule - site = schedule.site +def send_remove_alert(member: object=None) -> dict: + """ + Sends a "removed" email to the passed `Member` and + deletes member from DB - # getting object - data = get_item(object_id=object_id) - if not data['success']: - return {'success': False} + Expects: { + 'member': obj + } - item = data['item'] - item_type = data['item_type'] + Returns -> data: { + 'success': bool + } + """ - exp_list = create_exp_str(item=item, automation=automation, is_email=True) + # check if member exists as status "removed" + if Member.objects.filter(email=member.email, status="removed").exists(): - object_url = str(os.environ.get('CLIENT_URL_ROOT') + '/site/'+str(site.id)) - subject = f'Alert for {site.site_url}' - title = f'Alert for {site.site_url}' - pre_header = f'Alert for {site.site_url}' + # build email data + subject = 'Removed From Account' + title = 'Removed From Account' + pre_header = 'Removed From Account' pre_content = ( - f'Scanerr just finished running a {item_type} for {site.site_url}. ' - f'Below are the current stats:\n' + f'A user with the email "{member.account.user.username}" removed you '+ + f'from their Team on Cursion. Please let us know if there\'s been a mistake.' ) - content = ( - f'This message was triggered by an automation you created. ' - f'You can change the automation and schedule in your site\'s dashboard. ' - ) - subject = subject + greeting = 'Hi there,' + context = { + 'greeting' : greeting, 'title' : title, - 'subject': subject, + 'subject' : subject, + 'email': member.email, 'pre_header' : pre_header, 'pre_content' : pre_content, - 'exp_list': exp_list, - 'object_url' : object_url, - 'home_page' : os.environ.get('CLIENT_URL_ROOT'), - 'button_text' : 'View Site Dashboard', - 'content' : content, - 'email': email, + 'object_url' : None, + 'home_page' : settings.CLIENT_URL_ROOT, + 'content' : '', 'signature' : '- Cheers!', } + # send email sendgrid_email(message_obj=context) - # html_message = render_to_string('api/automation_email.html', context) - # plain_message = strip_tags(html_message) - # send_mail( - # from_email = os.getenv('EMAIL_HOST_USER'), - # subject = subject, - # message = plain_message, - # recipient_list = [email], - # html_message = html_message, - # fail_silently = True, - # ) + # delete member obj + member.delete() data = { 'success': True @@ -336,55 +197,293 @@ def automation_email(email=None, automation_id=None, object_id=None): +def create_exp(obj: object=None, alert: object=None) -> dict: + """ + Builds an expression list (exp_list = []) based + on the passed 'obj' and `Alert`. + + Expects: { + 'obj' : object (Scan, Test, CaseRun, FlowRun), + 'alert' : object + } + + Returns -> data: { + 'exp_list': list, + 'exp_str' : str, + } + """ + + # seting defaults + exp_list = [] + exp_str = '' + + # loop through alert expressions + for e in alert.expressions: + + # settign defaults + title = None + data = None + + # generate custom data and scores + if 'test_score' in e['data_type']: + title = 'Test Score' + data = str(round(obj.score, 2)) + if 'test_status' in e['data_type']: + status = '❌ FAILED' + if obj.status == 'passed': + status = '✅ PASSED' + title = 'Test Status' + data = status + if 'caserun_status' in e['data_type']: + status = '❌ FAILED' + if e['value'] == 'passed': + status = '✅ PASSED' + title = f'"{obj.title}"' + data = status + if 'flowrun_status' in e['data_type']: + status = '❌ FAILED' + if e['value'] == 'passed': + status = '✅ PASSED' + title = f'"{obj.title}"' + data = status + + # get title and data if None + if title == None: + definition = get_definition(e['data_type']) + if definition: + title = definition['name'] + data = str(eval(definition['value'])) + + # create data string + data_str = f' {title}: {data}\n' + exp_str += data_str + + # add to exp_list + exp_list.append({ + 'title': title, + 'data': data + }) + + # formating return data + data = { + 'exp_list': exp_list, + 'exp_str': exp_str, + } + + return data + + + + +def transpose_data(string: str=None, obj: object=None, secrets: list=[]) -> dict: + """ + Using 'definitions.py' replaces all vairables with definition data. + + Expects: { + 'string' : str (to be transposed) + 'obj' : object (Scan, Test, CaseRun, Report), + 'secrets' : list (account secrets) + } + + Returns -> transposed string + """ + + # decryption helper + def decrypt_secret(value): + f = Fernet(settings.SECRETS_KEY) + decoded = f.decrypt(value) + return decoded.decode('utf-8') + + # create secrets_list + secrets_list = [] + for secret in secrets: + secrets_list.append({ + 'key': '{{'+str(secret.name)+'}}', + 'value': decrypt_secret(secret.value) + }) + + # iterate through secrets and replace data + for item in secrets_list: + string = string.replace( + item['key'], + item['value'] + ) + + # iterate through definitions and + # replace {{vairables}} with str(value) first + for item in definitions: + string = string.replace( + ('{{'+str(item['key'])+'}}'), + str(item['value']) + ) + + # iterate through definitions and replace + # str(value) with eval(str(value)) + for item in definitions: + if item['value'] in string: + value = eval(item['value']) + data = value if value is not None else 0 + string = string.replace( + str(item['value']), + str(data) + ) + + # return updated string + return string + + + -def automation_report_email(email=None, automation_id=None, object_id=None): - if email and automation_id: - automation = Automation.objects.get(id=automation_id) - schedule = automation.schedule - site = schedule.site +def get_obj(object_id: str=None) -> dict: + """ + Tries to find an object that matches theh passed 'object_id'. + Expects: { + 'object_id': str, + } + + Returns -> data: { + 'obj' : object (Scan, Test, CaseRun, FlowRun, Report), + 'obj_type' : str, + 'success' : bool + } + """ + + # init obj + obj = None + obj_type = '' + success = False + + # check for obj + if not obj: + try: + obj = Test.objects.get(id=uuid.UUID(object_id)) + obj_type = 'Test' + success = True + except: + pass + if not obj: + try: + obj = Scan.objects.get(id=uuid.UUID(object_id)) + obj_type = 'Scan' + success = True + except: + pass + if not obj: try: - item = Report.objects.get(id=uuid.UUID(object_id)) - item_type = 'Report' + obj = CaseRun.objects.get(id=uuid.UUID(object_id)) + obj_type = 'CaseRun' + success = True except: + pass + if not obj: + try: + obj = FlowRun.objects.get(id=uuid.UUID(object_id)) + obj_type = 'FlowRun' + success = True + except: + pass + if not obj: + try: + obj = Report.objects.get(id=uuid.UUID(object_id)) + obj_type = 'Report' + success = True + except: + pass + + # format and return data + data = { + 'obj': obj, + 'obj_type': obj_type, + 'success': success + } + + return data + + + + +def alert_email(email: str=None, alert_id: str=None, object_id: str=None) -> dict: + """ + Sends an alert email to the User with + the passed 'email' + + Expects: { + 'email' : str, + 'alert_id' : str, + 'object_id' : str + } + + Returns -> data: { + 'success': bool + } + """ + + # check if data is present + if email and alert_id: + + # get alert + alert = Alert.objects.get(id=alert_id) + schedule = alert.schedule + + # getting object + data = get_obj(object_id=object_id) + if not data['success']: return {'success': False} - exp_list = '' - object_url = str(item.path) - subject = f'Report for {site.site_url}' - title = f'Report for {site.site_url}' - pre_header = f'Report for {site.site_url}' + # getting object data + obj = data['obj'] + obj_type = data['obj_type'] + + # clean obj_type + obj_name = obj_type.replace('Run', '') + + # deciding if "page" or "site" scope + if obj_type == 'CaseRun' or obj_type == 'FlowRun': + url = obj.site.site_url + else: + url = obj.page.page_url + + # build dash link + dash_link = f'{settings.CLIENT_URL_ROOT}/schedule' + + # generating expressions from alert + exp_list = create_exp( + obj=obj, + alert=alert + )['exp_list'] + + # build email data + object_url = f'{settings.CLIENT_URL_ROOT}/{obj_type.lower()}/{str(obj.id)}' + subject = f'Alert for {url}' + title = f'Alert for {url}' + pre_header = f'Alert for {url}' pre_content = ( - f'Scanerr just finished creating a {item_type} for {site.site_url}. ' - f'Please click the link below to access and download the report.\n' + f'Cursion just finished running a {obj_name} for {url}. ' + f'Below are the current stats:' ) content = ( - f'This message was triggered by an automation created with Scanerr. ' - f'You can change the automation and schedule in your site\'s dashboard. ' + f'This message was triggered by an alert you created. ' + f'You can change the alert and schedule in your ' + f'dashboard.' ) - subject = subject + context = { 'title' : title, + 'subject': subject, 'pre_header' : pre_header, 'pre_content' : pre_content, 'exp_list': exp_list, 'object_url' : object_url, - 'home_page' : os.environ.get('CLIENT_URL_ROOT'), - 'button_text' : 'View Report', + 'home_page' : settings.CLIENT_URL_ROOT, + 'button_text' : f'View {obj_name}', 'content' : content, + 'email': email, 'signature' : '- Cheers!', } - html_message = render_to_string('api/automation_email.html', context) - plain_message = strip_tags(html_message) - send_mail( - from_email = os.getenv('EMAIL_HOST_USER'), - subject = subject, - message = plain_message, - recipient_list = [email], - html_message = html_message, - fail_silently = True, - ) + # send email + sendgrid_email(message_obj=context) data = { 'success': True @@ -400,40 +499,71 @@ def automation_report_email(email=None, automation_id=None, object_id=None): -def automation_webhook( - request_type=None, - request_url=None, - request_data=None, - automation_id=None, - object_id=None, - ): - if request_type and automation_id and request_url and request_data and object_id: - automation = Automation.objects.get(id=automation_id) - schedule = automation.schedule - site = schedule.site +def alert_report_email(email: str=None, alert_id: str=None, object_id: str=None) -> dict: + """ + Sends an alert report email to the User with + the passed 'email' - # getting object - data = get_item(object_id=object_id) - if not data['success']: - return {'success': False} + Expects: { + 'email' : str, + 'alert_id' : str, + 'object_id' : str + } - item = data['item'] - item_type = data['item_type'] + Returns -> data: { + 'success': bool + } + """ + + # check if data is present + if email and alert_id: - pre_json_data = json.loads(request_data) - json_data = create_json_data(data=pre_json_data, obj=item) + # retrieving user + user = User.objects.get(email=email) + # get alert and deciding if "page" or "site" scope + alert = Alert.objects.get(id=alert_id) + schedule = alert.schedule + + # get `Report` if exists try: - if request_type == 'POST': - response = requests.post(request_url, data=json_data) - elif request_data == 'GET': - response = requests.get(request_url, params=json_data) + report = Report.objects.get(id=uuid.UUID(object_id)) + obj_type = 'Report' + url = report.page.page_url + except: + return {'success': False} - print(response.json()) + # build email data + object_url = str(report.path) + subject = f'Report for {url}' + title = f'Report for {url}' + pre_header = f'Report for {url}' + pre_content = ( + f'Cursion just finished creating a ' + f'Report for {url}. ' + f'Please click the link below to access and download the PDF.' + ) + content = ( + f'\nThis message was triggered by an alert created with Cursion. ' + f'You can change the alert and schedule in your ' + f'dashboard.' + ) - except: - data = {'success': False} + context = { + 'title' : title, + 'subject': subject, + 'pre_header' : pre_header, + 'pre_content' : pre_content, + 'object_url' : object_url, + 'home_page' : settings.CLIENT_URL_ROOT, + 'button_text' : 'View Report', + 'content' : content, + 'email': email, + 'signature' : '- Cheers!', + } + # send email + sendgrid_email(message_obj=context) data = { 'success': True } @@ -448,48 +578,75 @@ def automation_webhook( +def alert_phone(phone_number: str=None, alert_id: str=None, object_id: str=None) -> dict: + """ + Sends an SMS alert to the passed 'phone_number' + with the `Alert` data + + Expects: { + 'phone_number' : str, + 'alert_id' : str, + 'object_id' : str, + } + + Returns -> data: { + 'success': bool + } + """ + + # checking if data is present + if phone_number and alert_id and object_id: -def automation_phone(phone_number=None, automation_id=None, object_id=None): - if phone_number and automation_id and object_id: - automation = Automation.objects.get(id=automation_id) - schedule = automation.schedule - site = schedule.site + # getting schedule and alert + alert = Alert.objects.get(id=alert_id) + schedule = alert.schedule + account_id = str(schedule.account.id) # getting object - data = get_item(object_id=object_id) + data = get_obj(object_id=object_id) if not data['success']: return {'success': False} - item = data['item'] - item_type = data['item_type'] + # get obj and type + obj = data['obj'] + obj_type = data['obj_type'] - exp_str = create_exp_str(item=item, automation=automation) + # clean obj_type + obj_name = obj_type.replace('Run', '') - object_url = str(os.environ.get('CLIENT_URL_ROOT') + '/site/'+str(site.id)) + # deciding if "page" or "site" scope + if obj_type == 'CaseRun' or obj_type == 'FlowRun': + url = obj.site.site_url + else: + url = obj.page.page_url + + # build dash link + dash_link = f'{settings.CLIENT_URL_ROOT}/schedule' + + # build the exp_str + exp_str = create_exp(obj=obj, alert=alert)['exp_str'] + + # build message data + object_url = f'{settings.CLIENT_URL_ROOT}/{obj_type.lower()}/{obj.id}' pre_content = ( - f'Scanerr just finished running a {item_type} for {site.site_url}. ' - f'Below are the current stats:\n\n\t{exp_str}\n' + f'Cursion just finished running a {obj_name} for {url}. ' + f'Below are the current stats:\n\n{exp_str}\n' + f'View {obj_name}: {object_url}\n\n' ) content = ( - f'This message was triggered by an automation you created. ' - f'You can change the automation and schedule in your site\'s dashboard. ' + f'This message was triggered by an alert you created. ' + f'You can change the alert and schedule in your dashboard: {dash_link}' ) + body = f'Hi there,\n\n{pre_content}{content}' - body = f'Hi there,\n\n{pre_content}{content}\n{object_url}' - - account_sid = os.environ.get("TWILIO_SID") - auth_token = os.environ.get("TWILIO_AUTH_TOKEN") - client = Client(account_sid, auth_token) - - message = client.messages.create( - to=phone_number, - from_=os.environ.get('TWILIO_NUMBER'), + # send message + data = send_phone( + account_id=account_id, + object_id=object_id, + phone_number=phone_number, body=body ) - - data = { - 'success': True - } + return data else: data = { @@ -501,60 +658,75 @@ def automation_phone(phone_number=None, automation_id=None, object_id=None): -def automation_slack(automation_id=None, object_id=None): - if automation_id and object_id: - automation = Automation.objects.get(id=automation_id) - account = Account.objects.get(user=automation.user) - schedule = automation.schedule - site = schedule.site +def alert_slack(alert_id: str=None, object_id: str=None) -> dict: + """ + Sends a Slack alert with the `Alert` data + + Expects: { + 'alert_id' : str, + 'object_id' : str, + } + + Returns -> data: { + 'success': bool + } + """ + + # check if data is present + if alert_id and object_id: + + # getting schedule, account and alert + alert = Alert.objects.get(id=alert_id) + schedule = alert.schedule + account = schedule.account + # getting object - data = get_item(object_id=object_id) + data = get_obj(object_id=object_id) if not data['success']: return {'success': False} - item = data['item'] - item_type = data['item_type'] + # get obj and type + obj = data['obj'] + obj_type = data['obj_type'] + + # deciding if "page" or "site" scope + if obj_type == 'CaseRun' or obj_type == 'FlowRun': + url = obj.site.site_url + else: + url = obj.page.page_url + + # build dash link + dash_link = f'{settings.CLIENT_URL_ROOT}/schedule' - exp_str = create_exp_str(item=item, automation=automation) + # build exp_str + exp_str = create_exp(obj=obj, alert=alert)['exp_str'] - object_url = str(os.environ.get('CLIENT_URL_ROOT') + '/site/'+str(site.id)) + # clean obj_type + obj_name = obj_type.replace('Run', '') + + # build message data + object_url = f'{settings.CLIENT_URL_ROOT}/{obj_type}/{obj.id}' pre_content = ( - f'Scanerr just finished running a {item_type} for {site.site_url}. ' - f'Below are the current stats:\n\n\t{exp_str}\n' + f'Cursion just finished running a `{obj_name}` for {url}. ' + f'Below are the current stats:\n\n```{exp_str}```\n' + f'<{object_url}|*View {obj_name}*>\n\n' ) content = ( - f'This message was triggered by an automation you created. ' - f'You can change the automation and schedule in your site\'s dashboard. ' + f'This message was triggered by an alert you created. ' + f'You can change the alert and schedule in your ' + f'<{dash_link}|dashboard>.' ) + body = f'Hi there,\n\n{pre_content}{content}' - body = f'Hi there,\n\n{pre_content}{content}\n{object_url}' - - token = account.slack['bot_access_token'] - channel = account.slack['slack_channel_id'] - - client = WebClient(token=token) - try: - response = client.chat_postMessage( - channel=channel, - text=(body), - block=[ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": body, - - } - } - ] - ) - except SlackApiError as e: - assert e.response["error"] + # send slack message + data = send_slack( + account_id=account.id, + object_id=object_id, + body=body + ) - data = { - 'success': True - } + return data else: data = { @@ -566,38 +738,45 @@ def automation_slack(automation_id=None, object_id=None): - - - -def sendgrid_email(message_obj): +def sendgrid_email( + account_id: str=None, + object_id: str=None, + message_obj: dict=None + ) -> dict: """ Tries to send an email via the SendGrid API. - Expects the following: - "message_obj": { - 'pre_content': , - 'content': , - 'subject': , - 'title': , - 'pre_header': , - 'button_text': , - 'exp_list': , - 'email': , - 'template': , - 'object_url': , - 'signature': + Expects:{ + 'account_id' : str, + 'object_id' : str, + 'message_obj': dict { + 'plain_text': bool, + 'pre_content': str, + 'content': str, + 'subject': str, + 'title': str, + 'pre_header': str, + 'button_text': str, + 'exp_list': list, + 'email': str, + 'template': str, + 'object_url': str, + 'signature': str, + 'greeting': str, } + } - Returns --> data: { - 'message': True + Returns: { + 'success': bool, + 'message': str } """ - # defining data + plain_text = message_obj.get('plain_text', False) pre_content = message_obj.get('pre_content') content = message_obj.get('content') - subject = message_obj.get('subject') + subject = message_obj.get('subject', 'Alert from Cursion') title = message_obj.get('title') pre_header = message_obj.get('pre_header') button_text = message_obj.get('button_text') @@ -605,10 +784,27 @@ def sendgrid_email(message_obj): exp_list = message_obj.get('exp_list') object_url = message_obj.get('object_url') signature = message_obj.get('signature', '- Cheers!') + greeting = message_obj.get('greeting', 'Hi there,') + + if account_id: + # get account & secrets + account = Account.objects.get(id=account_id) + secrets = Secret.objects.filter(account=account) + + # get object + obj = get_obj(object_id)['obj'] + + # cleaning data + content = transpose_data(content, obj, secrets) + subject = transpose_data(subject, obj, secrets) + # replacing '\n' with
+ content = content.replace('\n', '
') + pre_content = content.replace('\n', '
') # build template data template_data = { + 'greeting': greeting, 'title' : title, 'pre_header' : pre_header, 'pre_content' : pre_content, @@ -628,29 +824,254 @@ def sendgrid_email(message_obj): if exp_list is not None: template = settings.AUTOMATION_TEMPLATE - # init SendGrid message message = Mail( - from_email=From('hello@scanerr.io', 'Scanerr'), # prod -> settings.EMAIL_HOST_USER + from_email=From(settings.EMAIL_HOST_USER, 'Cursion'), to_emails=email, ) - + # attach template data and id - message.dynamic_template_data = template_data - message.template_id = template + if not plain_text: + message.dynamic_template_data = template_data + message.template_id = template + + # building message as plain text + if plain_text: + message.subject = Subject(subject) + message.content = [ + Content( + mime_type="text/html", + content=content + ) + ] # send message try: sg = SendGridAPIClient(settings.SENDGRID_API_KEY) response = sg.send(message) status = True + msg = 'email sent successfully' except Exception as e: status = False - print(e.message) + msg = e.message + + # formatting resposne + data = { + 'success': status, + 'message': msg + } + + return data + + + + +def send_phone( + account_id: str=None, + object_id: str=None, + phone_number: str=None, + body: str=None + ) -> dict: + """ + Using Twilio, sends an SMS with the passed 'body' + top the passed 'phone_number' + + Expects: { + 'account_id' : str, + 'object_id' : str, + 'phone_number' : str, + 'body' : str, + } + + Returns: { + 'success': bool, + 'message': str + } + """ + + if account_id and object_id: + # get account & secrets + account = Account.objects.get(id=account_id) + secrets = Secret.objects.filter(account=account) + + # get object + obj = get_obj(object_id)['obj'] + + # cleaning data + body = transpose_data(body, obj, secrets) + + try: + # setup client + account_sid = settings.TWILIO_SID + auth_token = settings.TWILIO_AUTH_TOKEN + client = Client(account_sid, auth_token) + + # clean phone_number + phone_number = phone_number.strip().replace('(', '').replace(')', '').replace('-', '') + phone_number = ''.join(phone_number.split()) + + # send message + message = client.messages.create( + to=phone_number, + from_=settings.TWILIO_NUMBER, + body=body + ) + success = True + msg = 'sms sent successfully' + + except Exception as e: + print(e) + success = False + msg = str(e) + + data = { + 'success': success, + 'message': msg + } + return data + + + +def send_slack( + account_id: str=None, + object_id: str=None, + body: str=None + ) -> dict: + """ + Using Slack, sends an message with the passed 'body' + top the passed 'account'.channel + + Expects: { + 'account_id' : str, + 'object_id' : str, + 'body' : str, + } + + Returns: { + 'success': bool, + 'message': str + } + """ + + if account_id and object_id: + # get account & secrets + account = Account.objects.get(id=account_id) + secrets = Secret.objects.filter(account=account) + + # get object + obj = get_obj(object_id)['obj'] + + # cleaning data + body = transpose_data(body, obj, secrets) + + try: + # setup client + token = account.slack['bot_access_token'] + channel = account.slack['slack_channel_id'] + client = WebClient(token=token) + + # send message + response = client.chat_postMessage( + channel=channel, + text=(body), + block=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": body, + } + } + ] + ) + success = True + msg = 'slack message sent successfully' + + except SlackApiError as e: + print(e) + success = False + msg = str(e) data = { - 'success': status + 'success': success, + 'message': msg + } + return data + + + + +def send_webhook( + account_id: str=None, + object_id: str=None, + request_type: str=None, + url: str=None, + headers: dict=None, + payload: dict=None, + ) -> dict: + """ + Sends a GET or POST request to the passed 'url' + with the passed 'payload' & 'heasders' + + Expects: { + 'account_id' : str, + 'object_id' : str, + 'request_type' : str, + 'url' : str, + 'headers' : dict, + 'payload' : dict, + } + + Returns: { + 'success': bool, + 'message': str } + """ + + # get account & secrets + account = Account.objects.get(id=account_id) + secrets = Secret.objects.filter(account=account) + + # get object + obj = get_obj(object_id)['obj'] + + # cleaning data + cleaned_headers = transpose_data(headers, obj, secrets) + cleaned_payload = transpose_data(payload, obj, secrets) + cleaned_url = transpose_data(url, obj, secrets) + + # building json + json_payload = json.loads(cleaned_payload) if request_type == 'POST' else {} + json_headers = json.loads(cleaned_headers) + # send the request + try: + if request_type == 'POST': + response = requests.post( + url=cleaned_url, + headers=json_headers, + data=json.dumps(json_payload) + ).json() + + elif request_type == 'GET': + response = requests.get( + url=cleaned_url, + headers=json_headers + ).json() + + success = True + msg = str(response) + + except Exception as e: + success = False + msg = str(e) + + data = { + 'success': success, + 'message': msg + } return data + + + diff --git a/app/api/utils/archive/caser.py b/app/api/utils/archive/caser.py new file mode 100644 index 00000000..e36dbfb4 --- /dev/null +++ b/app/api/utils/archive/caser.py @@ -0,0 +1,899 @@ +# from .driver_p import driver_init as driver_p_init +# from .driver_s import driver_init as driver_init +# from .driver_s import driver_wait, quit_driver +# from .issuer import Issuer +# import time, uuid, json, boto3, os +# from selenium.webdriver.common.by import By +# from selenium.webdriver.common.keys import Keys +# from ..models import * +# from datetime import datetime +# from asgiref.sync import sync_to_async +# from cursion import settings + + + + + + +# class Caser(): +# """ +# Run a `CaseRun` for a specific `Site`. + +# Expects: { +# 'caserun' : object, +# } + +# - Use `Caser.run_s()` to run with selenium +# - Use `Caser.run_p()` to run with puppeteer + +# Returns -> None +# """ + + + + +# def __init__(self, caserun: object=None): +# self.caserun = caserun +# self.site_url = self.caserun.site.site_url +# self.steps = self.caserun.steps +# self.title = self.caserun.case.title +# self.configs = self.caserun.configs +# self.s_keys = { +# '+': Keys.ADD, +# 'Alt': Keys.ALT, +# 'ArrowDown': Keys.ARROW_DOWN, +# 'ArrowLeft': Keys.ARROW_LEFT, +# 'ArrowRight': Keys.ARROW_RIGHT, +# 'ArrowUp': Keys.ARROW_UP, +# 'Backspace': Keys.BACKSPACE, +# 'Control': Keys.CONTROL, +# '.': Keys.DECIMAL, +# 'Delete': Keys.DELETE, +# '/': Keys.DIVIDE, +# 'Enter': Keys.ENTER, +# '=': Keys.EQUALS, +# 'Escape': Keys.ESCAPE, +# 'Meta': Keys.META, +# '*': Keys.MULTIPLY, +# '0': Keys.NUMPAD0, +# '1': Keys.NUMPAD1, +# '2': Keys.NUMPAD2, +# '3': Keys.NUMPAD3, +# '4': Keys.NUMPAD4, +# '5': Keys.NUMPAD5, +# '6': Keys.NUMPAD6, +# '7': Keys.NUMPAD7, +# '8': Keys.NUMPAD8, +# '9': Keys.NUMPAD9, +# ';': Keys.SEMICOLON, +# 'Shift': Keys.SHIFT, +# 'Space': Keys.SPACE, +# '-': Keys.SUBTRACT, +# 'Tab': Keys.TAB +# } + + + + +# @sync_to_async +# def update_caserun( +# self, index: str=None, type: str=None, start_time: str=None, end_time: str=None, +# passed: bool=None, exception: str=None, time_completed: str=None, image: str=None, +# ) -> None: +# # updates Tescase for a puppeteer run (async) +# if start_time != None: +# self.caserun.steps[index][type]['time_created'] = str(start_time) +# if end_time != None: +# self.caserun.steps[index][type]['time_completed'] = str(end_time) +# if passed != None: +# self.caserun.steps[index][type]['passed'] = passed +# if exception != None: +# self.caserun.steps[index][type]['exception'] = str(exception) +# if image != None: +# self.caserun.steps[index][type]['image'] = str(image) +# if time_completed != None: +# self.caserun.time_completed = time_completed +# test_status = True +# for step in self.caserun.steps: +# if step['action']['passed'] == False: +# test_status = False +# if step['assertion']['passed'] == False: +# test_status = False +# self.caserun.passed = test_status + +# self.caserun.save() +# return None + + + + +# def update_caserun_s( +# self, index: str=None, type: str=None, start_time: str=None, end_time: str=None, +# passed: bool=None, exception: str=None, time_completed: str=None, image: str=None, +# ) -> None: +# # updates Tescase for a selenium run (async) +# if start_time != None: +# self.caserun.steps[index][type]['time_created'] = str(start_time) +# if end_time != None: +# self.caserun.steps[index][type]['time_completed'] = str(end_time) +# if passed != None: +# self.caserun.steps[index][type]['passed'] = passed +# if exception != None: +# self.caserun.steps[index][type]['exception'] = str(exception) +# if image != None: +# self.caserun.steps[index][type]['image'] = str(image) +# if time_completed != None: +# self.caserun.time_completed = time_completed +# test_status = True +# for step in self.caserun.steps: +# if step['action']['passed'] == False: +# test_status = False +# if step['assertion']['passed'] == False: +# test_status = False +# self.caserun.passed = test_status + +# self.caserun.save() +# return + + + + +# @sync_to_async +# def format_element(self, element): +# elememt = json.dumps(element).rstrip('"').lstrip('"') +# return str(element) + + + + +# def format_element_s(self, element): +# elememt = json.dumps(element).rstrip('"').lstrip('"') +# return str(element) + + + + +# async def save_screenshot(self, page: object=None) -> str: +# ''' +# Grabs & uploads a screenshot of the `page` +# passed in the params. + +# Returns -> `image_url` +# ''' + +# # setup boto3 configurations +# s3 = boto3.client( +# 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), +# aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), +# region_name=str(settings.AWS_S3_REGION_NAME), +# endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) +# ) + +# # setting id for image +# pic_id = uuid.uuid4() + +# # get screenshot +# await page.screenshot({'path': f'{pic_id}.png'}) + +# # seting up paths +# image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') +# remote_path = f'static/caseruns/{self.caserun.id}/{pic_id}.png' +# root_path = settings.AWS_S3_URL_PATH +# image_url = f'{root_path}/{remote_path}' + +# # upload to s3 +# with open(image, 'rb') as data: +# s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), +# remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} +# ) +# # remove local copy +# os.remove(image) + +# # returning image url +# return image_url + + + + +# def save_screenshot_s(self) -> str: +# ''' +# Grabs & uploads a screenshot of the `page` +# passed in the params. + +# Returns -> `image_url` +# ''' + +# # setup boto3 configurations +# s3 = boto3.client( +# 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), +# aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), +# region_name=str(settings.AWS_S3_REGION_NAME), +# endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) +# ) + +# # setting id for image +# pic_id = uuid.uuid4() + +# # get screenshot +# self.driver.save_screenshot(f'{pic_id}.png') + +# # seting up paths +# image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') +# remote_path = f'static/caseruns/{self.caserun.id}/{pic_id}.png' +# root_path = settings.AWS_S3_URL_PATH +# image_url = f'{root_path}/{remote_path}' + +# # upload to s3 +# with open(image, 'rb') as data: +# s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), +# remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} +# ) +# # remove local copy +# os.remove(image) + +# # returning image url +# return image_url + + + +# @sync_to_async +# def format_exception(self, exception: str) -> str: +# """ +# Cleans the passed `exception` of any +# system refs and unnecessary info + +# Expects: { +# "exception": str +# } + +# Returns -> str +# """ + +# split_e = str(exception).split('Stacktrace:') +# new_exception = split_e[0] + +# return new_exception + + + + +# def format_exception_s(self, exception: str) -> str: +# """ +# Cleans the passed `exception` of any +# system refs and unnecessary info + +# Expects: { +# "exception": str +# } + +# Returns -> str +# """ + +# split_e = str(exception).split('Stacktrace:') +# new_exception = split_e[0] + +# return new_exception + + + + +# def run_s(self) -> None: +# """ +# Runs the self.caserun using selenium as the driver + +# Returns -> None +# """ + +# print(f'beginning caserun for {self.site_url} \ +# using case {self.title}') + +# # initate driver +# self.driver = driver_init( +# window_size=self.configs['window_size'], +# device=self.configs['device'] +# ) + +# # setting implict wait_time for driver +# self.driver.implicitly_wait(self.configs['max_wait_time']) + +# i = 0 +# for step in self.steps: +# print(f'-- running step #{i+1} --') + +# # adding catch if nav is not first +# if i == 0 and step['action']['type'] != 'navigate': +# print(f'navigating to {self.site_url} before first step') +# # using selenium, navigate to site root path & wait for page to load +# self.driver.get(f'{self.site_url}') +# time.sleep(int(self.configs['min_wait_time'])) + +# if step['action']['type'] == 'navigate': +# exception = None +# passed = True +# self.update_caserun_s( +# index=i, type='action', +# start_time=datetime.now() +# ) + +# try: +# print(f'navigating to {self.site_url}{step["action"]["path"]}') +# # using selenium, navigate to requested path & wait for page to load +# driver_wait( +# driver=self.driver, +# interval=int(self.configs.get('interval', 1)), +# min_wait_time=int(self.configs.get('min_wait_time', 3)), +# max_wait_time=int(self.configs.get('max_wait_time', 30)), +# ) +# self.driver.get(f'{self.site_url}{step["action"]["path"]}') +# time.sleep(int(self.configs['min_wait_time'])) +# image = self.save_screenshot_s() + +# except Exception as e: +# image = self.save_screenshot_s() +# exception = self.format_exception_s(e) +# passed = False + +# self.update_caserun_s( +# index=i, type='action', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + + +# if step['action']['type'] == 'scroll': +# exception = None +# passed = True +# self.update_caserun_s( +# index=i, type='action', +# start_time=datetime.now() +# ) + +# try: +# print(f'scrolling -> {step["action"]["value"]}') + +# # scrolling using plain JavaScript +# self.driver.execute_script(f'window.scrollTo({step["action"]["value"]});') +# time.sleep(int(self.configs.get('min_wait_time', 3))) + +# # get image +# image = self.save_screenshot_s() + +# except Exception as e: +# image = self.save_screenshot_s() +# exception = self.format_exception_s(e) +# passed = False + +# self.update_caserun_s( +# index=i, type='action', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + + +# if step['action']['type'] == 'click': +# exception = None +# passed = True +# self.update_caserun_s( +# index=i, type='action', +# start_time=datetime.now() +# ) + +# try: +# print(f'clicking element -> {step["action"]["element"]}') +# # using selenium, find and click on the 'element' +# selector = self.format_element_s(step["action"]["element"]) +# element = self.driver.find_element(By.CSS_SELECTOR, selector) + +# # scrolling to element using plain JavaScript +# self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') +# self.driver.execute_script("arguments[0].scrollIntoView();", element) +# self.driver.execute_script("window.scrollBy(0, -100);") +# time.sleep(int(self.configs.get('min_wait_time', 3))) + +# # clicking element +# element.click() +# time.sleep(int(self.configs['min_wait_time'])) +# image = self.save_screenshot_s() + +# except Exception as e: +# image = self.save_screenshot_s() +# exception = self.format_exception_s(e) +# passed = False + +# self.update_caserun_s( +# index=i, type='action', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# if step['action']['type'] == 'change': +# exception = None +# passed = True +# self.update_caserun_s( +# index=i, type='action', +# start_time=datetime.now() +# ) + +# try: +# print(f'changing element to value -> {step["action"]["value"]}') +# # using selenium, find and change the 'element'.value +# selector = self.format_element_s(step["action"]["element"]) +# element = self.driver.find_element(By.CSS_SELECTOR, selector) + +# # scrolling to element and back down a bit +# self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') +# self.driver.execute_script("arguments[0].scrollIntoView();", element) +# self.driver.execute_script("window.scrollBy(0, -100);") +# time.sleep(int(self.configs.get('min_wait_time', 3))) + +# # changing value of element +# value = step["action"]["value"] +# element.send_keys(value) +# time.sleep(int(self.configs['min_wait_time'])) +# image = self.save_screenshot_s() + +# except Exception as e: +# image = self.save_screenshot_s() +# exception = self.format_exception_s(e) +# passed = False + +# self.update_caserun_s( +# index=i, type='action', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# if step['action']['type'] == 'keyDown': +# exception = None +# passed = True +# self.update_caserun_s( +# index=i, type='action', +# start_time=datetime.now() +# ) + +# try: +# print(f'keyDown action for key -> {step["action"]["key"]}') +# # getting last known element +# n = (i - 1) +# elm = None +# while True: +# elm = self.steps[n]['action']['element'] +# if elm != None and len(elm) != 0: +# break +# n -= 1 +# selector = self.format_element_s(elm) + +# # using selenium, find elemenmtn and send 'Key' event +# selector = self.format_element_s(step["action"]["element"]) +# element = self.driver.find_element(By.CSS_SELECTOR, selector) + +# # scrolling to element and back down a bit +# self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') +# self.driver.execute_script("arguments[0].scrollIntoView();", element) +# self.driver.execute_script("window.scrollBy(0, -100);") +# time.sleep(int(self.configs.get('min_wait_time', 3))) + +# # using selenium, press the selected key +# element.send_keys(self.s_keys.get(step["action"]["key"], step["action"]["key"])) +# time.sleep(int(self.configs['min_wait_time'])) +# image = self.save_screenshot_s() + +# except Exception as e: +# image = self.save_screenshot_s() +# exception = self.format_exception_s(e) +# passed = False + +# self.update_caserun_s( +# index=i, type='action', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# if step['assertion']['type'] == 'match': +# exception = None +# passed = True +# self.update_caserun_s( +# index=i, type='action', +# start_time=datetime.now() +# ) + +# try: +# print(f'asserting that element value -> {step["assertion"]["element"]} matches {step["assertion"]["value"]}') +# # using selenium, find elememt and assert if element.text == assertion.text +# selector = self.format_element_s(step["action"]["element"]) +# element = self.driver.find_element(By.CSS_SELECTOR, selector) + +# # scrolling to element and back down a bit +# self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') +# self.driver.execute_script("arguments[0].scrollIntoView();", element) +# self.driver.execute_script("window.scrollBy(0, -100);") +# time.sleep(int(self.configs.get('min_wait_time', 3))) + +# # gettintg elem text +# elementText = self.driver.execute_script(f'return document.querySelector("{selector}").textContent') +# elementText = elementText.strip() +# print(f'elementText => {elementText}') +# print(f'value => {step["assertion"]["value"]}') + +# # assert text +# assert elementText == step["assertion"]["value"] +# image = self.save_screenshot_s() + +# except Exception as e: +# image = self.save_screenshot_s() +# exception = self.format_exception_s(e) +# passed = False + +# self.update_caserun_s( +# index=i, type='action', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# if step['assertion']['type'] == 'exists': +# exception = None +# passed = True +# self.update_caserun_s( +# index=i, type='assertion', +# start_time=datetime.now() +# ) + +# try: +# print(f'asserting that element -> {step["assertion"]["element"]} exists') +# # using puppeteer, find elememt and assert it exists +# selector = self.format_element_s(step["action"]["element"]) +# element = self.driver.find_element(By.CSS_SELECTOR, selector) + +# # scrolling to element and back down a bit +# self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') +# self.driver.execute_script("arguments[0].scrollIntoView();", element) +# self.driver.execute_script("window.scrollBy(0, -100);") + +# # scrolling to element using plain JavaScript +# self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') +# element = self.driver.find_element(By.CSS_SELECTOR, selector) +# image = self.save_screenshot_s() + +# except Exception as e: +# image = self.save_screenshot_s() +# exception = self.format_exception_s(e) +# passed = False + +# self.update_caserun_s( +# index=i, type='assertion', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# i += 1 + +# self.update_caserun_s( +# time_completed=datetime.now() +# ) +# quit_driver(driver=self.driver) +# print('-- caserun run complete --') + +# if not self.caserun.passed and self.caserun.configs.get('create_issue'): +# print('generating new Issue...') +# Issuer(caserun=self.caserun).build_issue() + +# return None + + + + +# async def run_p(self) -> None: +# """ +# Runs the self.caserun using pupeteer as the driver + +# Returns -> None +# """ + +# print(f'beginning caserun for {self.site_url} \ +# using case {self.title}') + +# # initate driver +# self.driver = await driver_p_init() + +# # init page obj +# self.page = await self.driver.newPage() + +# # setting up page with configs +# sizes = self.configs['window_size'].split(',') +# is_mobile = False +# if self.configs['device'] == 'mobile': +# is_mobile = True + +# self.page_options = { +# 'waitUntil': 'networkidle0', +# 'timeout': int(self.configs['max_wait_time'])*1000 +# } + +# print(f'setting max timeout to -> {int(self.configs["max_wait_time"])}s') + +# viewport = { +# 'width': int(sizes[0]), +# 'height': int(sizes[1]), +# 'isMobile': is_mobile, +# } + +# userAgent = ( +# "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 \ +# (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36" +# ) + +# emulate_options = { +# 'viewport': viewport, +# 'userAgent': userAgent +# } + +# if self.configs['device'] == 'mobile': +# await self.page.emulate(emulate_options) +# else: +# await self.page.setViewport(viewport) + + +# i = 0 +# for step in self.steps: +# print(f'-- running step #{i+1} --') + +# # adding catch if nav is not first +# if i == 0 and step['action']['type'] != 'navigate': +# print(f'navigating to {self.site_url} before first step') +# # using puppeteer, navigate to site root path & wait for page to load +# await self.page.goto(f'{self.site_url}', self.page_options) +# time.sleep(int(self.configs['min_wait_time'])) + +# if step['action']['type'] == 'navigate': +# exception = None +# passed = True +# await self.update_caserun( +# index=i, type='action', +# start_time=datetime.now() +# ) + +# try: +# print(f'navigating to {self.site_url}{step["action"]["path"]}') +# # using puppeteer, navigate to requested path & wait for page to load +# await self.page.goto(f'{self.site_url}{step["action"]["path"]}', self.page_options) +# time.sleep(int(self.configs['min_wait_time'])) +# image = await self.save_screenshot(page=self.page) + +# except Exception as e: +# image = await self.save_screenshot(page=self.page) +# exception = await self.format_exception(e) +# passed = False + + +# await self.update_caserun( +# index=i, type='action', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# if step['action']['type'] == 'scroll': +# exception = None +# passed = True +# await self.update_caserun( +# index=i, type='action', +# start_time=datetime.now() +# ) + +# try: +# print(f'scrolling -> {step["action"]["value"]}') + +# # scrolling using plain JavaScript +# await self.page.evaluate(f'window.scrollTo({step["action"]["value"]});') +# time.sleep(int(self.configs['min_wait_time'])) + +# # get image +# image = await self.save_screenshot(page=self.page) + +# except Exception as e: +# image = await self.save_screenshot(page=self.page) +# exception = await self.format_exception(e) +# passed = False + +# await self.update_caserun( +# index=i, type='action', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# if step['action']['type'] == 'click': +# exception = None +# passed = True +# await self.update_caserun( +# index=i, type='action', +# start_time=datetime.now() +# ) + +# try: +# print(f'clicking element -> {step["action"]["element"]}') +# # using puppeteer, find and click on the 'element' +# selector = await self.format_element(step["action"]["element"]) +# await self.page.waitForSelector(selector, timeout=(int(self.configs['max_wait_time'])*1000)) +# # scrolling to element using plain JavaScript +# await self.page.evaluate(f'document.querySelector("{selector}").scrollIntoView()') +# element = await self.page.J(selector) +# await element.click() +# time.sleep(int(self.configs['min_wait_time'])) +# image = await self.save_screenshot(page=self.page) + +# except Exception as e: +# image = await self.save_screenshot(page=self.page) +# exception = await self.format_exception(e) +# passed = False + +# await self.update_caserun( +# index=i, type='action', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# if step['action']['type'] == 'change': +# exception = None +# passed = True +# await self.update_caserun( +# index=i, type='action', +# start_time=datetime.now() +# ) + +# try: +# print(f'changing element to value -> {step["action"]["value"]}') +# # using puppeteer, find and click on the 'element' +# if step["action"]["element"] != (None or ''): +# selector = await self.format_element(step["action"]["element"]) +# await self.page.waitForSelector(selector, timeout=(int(self.configs['max_wait_time'])*1000)) +# # scrolling to element using plain JavaScript +# await self.page.evaluate(f'document.querySelector("{selector}").scrollIntoView()') +# element = await self.page.J(selector) +# await element.click(clickCount=3) +# await self.page.keyboard.type(step["action"]["value"]) +# time.sleep(int(self.configs['min_wait_time'])) +# image = await self.save_screenshot(page=self.page) + +# except Exception as e: +# image = await self.save_screenshot(page=self.page) +# exception = await self.format_exception(e) +# passed = False + +# await self.update_caserun( +# index=i, type='action', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# if step['action']['type'] == 'keyDown': +# exception = None +# passed = True +# await self.update_caserun( +# index=i, type='action', +# start_time=datetime.now() +# ) + +# try: +# print(f'keyDown action for key -> {step["action"]["key"]}') +# # using puppeteer, press the selected key +# await self.page.keyboard.press(step['action']['key']) +# time.sleep(int(self.configs['min_wait_time'])) +# image = await self.save_screenshot(page=self.page) + +# except Exception as e: +# image = await self.save_screenshot(page=self.page) +# exception = await self.format_exception(e) +# passed = False + +# await self.update_caserun( +# index=i, type='action', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# if step['assertion']['type'] == 'match': +# exception = None +# passed = True +# await self.update_caserun( +# index=i, type='assertion', +# start_time=datetime.now() +# ) + +# try: +# print(f'asserting that element value -> {step["assertion"]["element"]} matches {step["assertion"]["value"]}') +# # using puppeteer, find elememt and assert if element.text == assertion.text +# selector = await self.format_element(step["assertion"]["element"]) +# await self.page.waitForSelector(selector, timeout=(int(self.configs['max_wait_time'])*1000)) +# # scrolling to element using plain JavaScript +# await self.page.evaluate(f'document.querySelector("{selector}").scrollIntoView()') +# elementText = await self.page.evaluate(f'document.querySelector("{selector}").textContent') +# elementText = elementText.strip() +# print(f'elementText => {elementText}') +# print(f'value => {step["assertion"]["value"]}') +# assert elementText == step["assertion"]["value"] +# image = await self.save_screenshot(page=self.page) + +# except Exception as e: +# image = await self.save_screenshot(page=self.page) +# exception = await self.format_exception(e) +# passed = False + +# await self.update_caserun( +# index=i, type='assertion', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# if step['assertion']['type'] == 'exists': +# exception = None +# passed = True +# await self.update_caserun( +# index=i, type='assertion', +# start_time=datetime.now() +# ) + +# try: +# print(f'asserting that element -> {step["assertion"]["element"]} exists') +# # using puppeteer, find elememt and assert it exists +# selector = await self.format_element(step["assertion"]["element"]) +# await self.page.waitForSelector(selector, timeout=(int(self.configs['max_wait_time'])*1000)) +# await self.page.J(selector) +# image = await self.save_screenshot(page=self.page) + +# except Exception as e: +# image = await self.save_screenshot(page=self.page) +# exception = await self.format_exception(e) +# passed = False + +# await self.update_caserun( +# index=i, type='assertion', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# i += 1 +# await self.update_caserun( +# time_completed=datetime.now() +# ) +# await self.driver.close() +# print('-- caserun run complete --') + +# if not self.caserun.passed and self.caserun.configs.get('create_issue'): +# print('generating new Issue...') +# Issuer(caserun=self.caserun).build_issue() + +# return None + + + + + + + \ No newline at end of file diff --git a/app/api/utils/archive/driver_p.py b/app/api/utils/archive/driver_p.py new file mode 100644 index 00000000..389188a1 --- /dev/null +++ b/app/api/utils/archive/driver_p.py @@ -0,0 +1,263 @@ +# from pyppeteer import launch +# from cursion import settings +# import time, os, sys, datetime + + + + + + +# async def driver_init(window_size: str='1920,1080', wait_time: int=30) -> object: +# """ +# Starts a new puppeteer driver instance + +# Expects: { +# 'window_size' : str, +# 'wait_time' : int +# } + +# Returns -> driver object +# """ + +# # parsing window sizes +# sizes = window_size.split(',') + +# # setting browser options +# options = { +# 'executablePath': os.environ.get('CHROME_BROWSER'), +# 'args': [ +# '--no-sandbox', +# '--disable-dev-shm-usage', +# '--force-device-scale-factor=0.5', +# 'ignore-certificate-errors', +# '--hide-scrollbars', +# f'--window-size={window_size}', +# ], +# 'defaultViewport': { +# 'width': int(sizes[0]), +# 'height': int(sizes[1]), +# }, +# # 'timeout': wait_time * 1000 +# } + +# # launching driver +# driver = await launch( +# options=options, +# headless=True, +# handleSIGINT=False, +# handleSIGTERM=False, +# handleSIGHUP=False +# ) + +# # return driver +# return driver + + + + +# async def interact_with_page(page: object=None) -> object: +# # simulate mouse movement +# # and returns the page object +# await page.mouse.move(0, 0) +# await page.mouse.move(0, 50) +# return page + + + + +# async def wait_for_page(page: object=None, max_wait_time: int=30) -> object: +# """ +# Expects the puppeteer page instance and waits +# for either the page to fully load or the max_wait_time +# to expire before returning. + +# Expects: { +# 'page' : object, +# 'max_wait_time' : int +# } + +# Returns -> page +# """ + +# print(f'waiting for page load or {str(max_wait_time)} seconds') + +# timeout = 0 +# page_state = 'loading' + +# while int(timeout) < int(max_wait_time) and page_state != 'complete': +# page_state = await page.evaluate('document.readyState') +# print(f'document state is {page_state}') +# time.sleep(1) +# timeout += 1 + +# return page + + + + +# async def driver_test() -> None: +# """ +# Spins up a puppeteer driver instance and +# tests to ensure it can access the browser and internet + +# Returns -> None +# """ + +# print("Testing puppeteer instalation and integration...") +# message = 'Puppeteer was unable to start\n\n' +# status = 'Failed' + +# # testing puppeteer +# try: +# driver = await driver_init() +# page = await driver.newPage() +# await page.goto('https://google.com', {'waitUntil': 'networkidle0'}) +# await interact_with_page(page) +# title = await page.title() +# assert title == 'Google' +# if title == 'Google': +# status = 'Success' +# message = 'Puppeteer installed and working \N{check mark} \n' + +# # log exception +# except Exception as e: +# print(e) + +# # logging test results +# sys.stdout.write( +# '--- ' + status + ' ---\n'+ message +# ) + +# # quiting driver +# try: +# await driver.close() +# except: +# pass + +# return None + + + + +# async def get_data(url: str=None, configs: dict=None) -> dict: +# """ +# Using the puppeteer driver, navigates to the passed +# 'url' and records the page source and any +# present console errors & warnings + +# Expects: { +# url : str, +# configs : dict +# } + +# Returns -> data: { +# 'html' : str, +# 'logs' : dict, +# } +# """ + +# # initing the driver +# sizes = configs['window_size'].split(',') +# driver = await driver_init(window_size=configs['window_size']) +# page = await driver.newPage() + +# # setting driver configs +# page_options = { +# 'waitUntil': 'networkidle0', +# # 'timeout': configs['max_wait_time']*1000 +# } +# viewport = { +# 'width': int(sizes[0]), +# 'height': int(sizes[1]), +# } +# userAgent = ( +# "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 \ +# (KHTML, like Gecko) Chrome/122.0.6261.119 Safari/537.36" +# ) +# await page.setViewport(viewport) +# if configs['device'] == 'mobile': +# await page.setUserAgent(userAgent) + +# # defining logs +# logs = [] + +# def record_logs(log): +# # helper method to record console +# # logs in the issues tab +# if log.type == 'error': +# if '.js' in log.text: +# source = 'javascript' +# elif 'http' in log.text: +# source = 'network' +# else: +# source = 'other' +# log_obj = { +# "level": "SEVERE", +# "source": source, +# "message": str(log.text), +# "timestamp": int(datetime.datetime.now().timestamp() * 1000) +# } +# logs.append(log_obj) +# elif log.type == 'warning': +# if '.js' in log.text: +# source = 'javascript' +# elif 'http' in log.text: +# source = 'network' +# else: +# source = 'other' +# log_obj = { +# "level": "WARNING", +# "source": source, +# "message": str(log.text), +# "timestamp": int(datetime.datetime.now().timestamp() * 1000) +# } +# logs.append(log_obj) + +# def record_network(request): +# # helper method to record console +# # network issues in the issues tab +# log_obj = { +# "level": "SEVERE", +# "source": "network", +# "message": f'{request.failure()["errorText"]} {request.url}', +# "timestamp": int(datetime.datetime.now().timestamp() * 1000) +# } +# logs.append(log_obj) + +# def record_error(error): +# # helper method to record console +# # page errors in the issues tab +# err = str(error).split(' at ')[0] +# log_obj = { +# "level": "SEVERE", +# "source": "javascript", +# "message": f'{err}', +# "timestamp": int(datetime.datetime.now().timestamp() * 1000) +# } +# logs.append(log_obj) + +# # getting console logs, warnings, and errors +# page.on('console', lambda log : record_logs(log)) +# page.on('requestfailed', lambda request : record_network(request)) +# page.on('pageerror', lambda error : record_error(error)) + +# # navigate to requested url +# await page.goto(url, page_options) + +# # await page.waitForNavigation(navWaitOpt) +# await wait_for_page(page=page) +# await interact_with_page(page) +# html = await page.content() + +# # quitting driver +# await driver.close() + +# # returning data +# data = { +# 'html': html, +# 'logs': logs, +# } + +# return data + + diff --git a/app/api/utils/archive/imager.py b/app/api/utils/archive/imager.py new file mode 100644 index 00000000..db57392a --- /dev/null +++ b/app/api/utils/archive/imager.py @@ -0,0 +1,797 @@ +# from .driver_s import driver_init, driver_wait, quit_driver +# from .driver_p import driver_init as driver_init_p, wait_for_page +# from ..models import Site, Scan, Test, Mask +# from skimage.metrics import structural_similarity +# from cursion import settings +# from PIL import Image as I, ImageChops, ImageStat +# from datetime import datetime +# from asgiref.sync import sync_to_async +# import time, os, sys, json, uuid, boto3, \ +# statistics, shutil, numpy, cv2 + + + + + + +# class Imager(): +# """ +# High level Image handler used to compare screenshots of +# a website. + +# Also known as VRT or Visual Regression Testing. +# Contains three methods scan_s(), scan_p(), test(). +# The _p appendage denotes using Puppeteer as the webdriver +# and the _s appendage denotes using Selenium as the webdriver: + +# def scan_s(driver=None) -> using selenium +# grabs multiple screenshots of the website +# and uploads them to s3. + +# def scan_p() -> using puppeteer +# grabs multiple screenshots of the website +# and uploads them to s3. + +# def test(test=) -> compares each +# screenshot in the two scans and records +# a score out of 100% + +# """ + + + + +# def __init__(self, scan: object=None, configs: dict=None): + +# # main scan object +# self.scan = scan + +# # main configs object +# self.configs = configs + +# # main image_array for scans +# self.image_array = [] + +# # setup boto3 configurations +# self.s3 = boto3.client( +# 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), +# aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), +# region_name=str(settings.AWS_S3_REGION_NAME), +# endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) +# ) + +# # scripts +# self.pause_video_script = ( +# """ +# document.querySelectorAll('video').forEach(vid => vid.pause()); +# document.querySelectorAll('video').forEach(vid => vid.currentTime=0); +# """ +# ) +# self.set_jquery = ( +# """ +# var jq = document.createElement('script'); +# jq.src = "https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"; +# document.getElementsByTagName('head')[0].appendChild(jq); +# """ +# ) +# self.pause_animations_script = ( +# """ +# const styleElement = document.createElement('style');styleElement.setAttribute('id','style-tag'); +# const styleTagCSSes = document.createTextNode('*,:after,:before{-webkit-transition:none!important;-moz-transition:none!important;-ms-transition:none!important;-o-transition:none!important;transition:none!important;-webkit-transform:none!important;-moz-transform:none!important;-ms-transform:none!important;-o-transform:none!important;-webkit-animation:none!important;animation:none!important;transform:none!important;transition-delay:0s!important;transition-duration:0s!important;animation-delay:-0.0001s!important;animation-duration:0s!important;animation-play-state:paused!important;caret-color:transparent!important;color-adjust:exact!important;}'); +# styleElement.appendChild(styleTagCSSes); +# document.head.appendChild(styleElement); +# """ +# ) + + + + +# def check_timeout(self, timeout: int, start_time: str) -> bool: +# """ +# Checks to see if the current time exceedes the alotted timeout. + +# Returns -> True if timeout exceeded +# """ +# current = datetime.now() +# diff = current - start_time +# if diff.total_seconds() >= int(timeout): +# print('exceeded timeout') +# return True +# else: +# return False + + + + +# def add_images(self, im1: object, im2: object) -> object: +# """ +# Joins img1 and im2 vertically and saves as "new_img" + +# Returns -> new_img +# """ +# im1 = I.open(im1) +# im2 = I.open(im2) +# new_img = I.new('RGB', (im1.width, im1.height + im2.height)) +# new_img.paste(im1, (0, 0)) +# new_img.paste(im2, (0, im1.height)) +# return new_img + + + + +# def save_image(self, pic_id: str, image: object) -> None: +# """ +# Upload image to s3, save info as image_obj, +# add image_obj to image_array, & remove image file + +# Returns -> None +# """ +# remote_path = f'static/sites/{self.scan.site.id}/{self.scan.page.id}/{self.scan.id}/{pic_id}.png' +# root_path = settings.AWS_S3_URL_PATH +# image_url = f'{root_path}/{remote_path}' + +# # upload to s3 +# with open(image, 'rb') as data: +# self.s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), +# remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} +# ) + +# # create image obj and add to list +# img_obj = { +# "index": 0, +# "id": str(pic_id), +# "url": image_url, +# "path": remote_path, +# } +# self.image_array.append(img_obj) + +# print(f'adding {img_obj["url"]} to image_array') + +# # remove local copy +# os.remove(image) + +# return None + + + + +# def scan_s(self, driver: object=None) -> list: +# """ +# Grabs full length screenshots of the website and uploads +# them to s3. + +# Expects: { +# 'driver': object +# } + +# Returns -> self.image_array list +# """ + +# # initialize driver if not passed as param +# driver_present = True +# if not driver: +# driver = driver_init() +# driver_present = False + +# # request page_url +# driver.get(self.scan.page.page_url) + +# # waiting for network requests to resolve +# driver_wait( +# driver=driver, +# interval=int(self.configs.get('interval', 5)), +# min_wait_time=int(self.configs.get('min_wait_time', 10)), +# max_wait_time=int(self.configs.get('max_wait_time', 30)), +# ) + +# # defining browser demesions +# sizes = self.configs.get('window_size', '1920,1080').split(',') + +# # getting full_page_height +# if self.configs.get('auto_height', True): +# full_page_height = driver.execute_script("return document.scrollingElement.scrollHeight;") +# sizes = self.configs.get('window_size', '1920,1080').split(',') +# driver.set_window_size(int(sizes[0]), int(full_page_height)) + + +# if self.configs.get('disable_animations') == True: +# # inserting animation pausing script +# try: +# driver.execute_script(self.pause_animations_script) +# except: +# print('cannot pause animations') + +# # inserting video pausing scripts +# try: +# driver.execute_script(self.pause_video_script) +# except: +# print('cannnot pause videos') + +# # mask all listed ids +# if self.configs.get('mask_ids') is not None and self.configs.get('mask_ids') != '': +# ids = self.configs.get('mask_ids').split(',') +# for id in ids: +# try: +# driver.execute_script(f"document.getElementById('{id}').style.visibility='hidden';") +# print('masked an element') +# except: +# print('cannot find element via id provided') + +# # mask all Global mask ids that are active +# active_masks = Mask.objects.filter(active=True) +# if len(active_masks) != 0: +# for mask in active_masks: +# try: +# driver.execute_script(f"document.getElementById('{mask.mask_id}').style.visibility='hidden';") +# print('masked an element') +# except: +# print('cannot find element via global mask id provided') + +# # scroll one frame at a time and capture screenshot +# final_img = None +# index = 0 +# last_height = -1 +# bottom = False +# start_time = datetime.now() +# while not bottom: + +# # checking if maxed out time +# if self.check_timeout(self.configs.get('timeout', 300), start_time): +# break + +# # scroll single frame +# if index != 0: +# driver.execute_script("window.scrollBy(0, document.documentElement.clientHeight);") +# time.sleep(int(self.configs.get('min_wait_time', 10))) + +# # get current position and compare to previous +# new_height = driver.execute_script("return window.pageYOffset + document.documentElement.clientHeight") +# height_diff = new_height - last_height + +# print(f'new_height => {new_height} | height_diff => {height_diff}') + +# if height_diff > 20: +# last_height = new_height +# pic_id = uuid.uuid4() + +# # waiting for network requests to resolve +# driver_wait( +# driver=driver, +# interval=int(self.configs.get('interval', 5)), +# min_wait_time=int(self.configs.get('min_wait_time', 10)), +# max_wait_time=int(self.configs.get('max_wait_time', 30)), +# ) + +# # get screenshot +# driver.save_screenshot(f'{pic_id}.png') +# image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') + +# # resizing image to remove duplicate portions +# img = I.open(image) +# width, height = img.size +# left = 0 +# top = height - (height_diff/2) +# right = width +# _bottom = height +# new_img = img.crop((left, top, right, _bottom)) +# new_img.save(image, quality=100) + +# # adding new image to bottom of existing image (if not index = 0) +# pic_id_2 = uuid.uuid4() +# if index != 0 and final_img is not None: +# self.add_images(final_img, image).save(f'{pic_id_2}.png') +# os.remove(final_img) +# final_img = os.path.join(settings.BASE_DIR, f'{pic_id_2}.png') +# else: +# I.open(image).save(f'{pic_id_2}.png') +# final_img = os.path.join(settings.BASE_DIR, f'{pic_id_2}.png') + +# os.remove(image) +# index += 1 + +# else: +# bottom = True + +# # saving image +# self.save_image(pic_id=pic_id_2, image=final_img) + +# # clean up +# if not driver_present: +# quit_driver(driver) + +# # return images +# return self.image_array + + + + +# async def scan_p(self) -> list: +# """ +# Using Puppeteer, grabs full length screenshots of the website and uploads +# them to s3. + +# Returns -> self.image_array list +# """ + +# @sync_to_async +# def get_page(): +# _page = self.scan.page +# return _page + +# # getting Cursion `page` object +# _page = await get_page() + +# # starting up puppeteer driver +# driver = await driver_init_p( +# window_size=self.configs.get('window_size', '1920,1080'), +# wait_time=int(self.configs.get('max_wait_time', 30)) +# ) + +# # initing new puppeteer page +# page = await driver.newPage() + +# # setting configs for driver +# sizes = self.configs.get('window_size', '1920,1080').split(',') +# is_mobile = False +# if self.configs.get('device') == 'mobile': +# is_mobile = True + +# page_options = { +# 'waitUntil': 'networkidle0', +# # 'timeout': int(self.configs.get('max_wait_time', 30))*1000 +# } + +# # requesting page_url to get height of +# await page.goto(_page.page_url, page_options) + +# # waiting for page to load +# await wait_for_page(page=page) + +# # getting full page_height +# page_height = int(sizes[1]) +# if self.configs.get('auto_height', True): +# page_height = await page.evaluate("document.scrollingElement.scrollHeight;") + +# # setting more driver configs +# viewport = { +# 'width': int(sizes[0]), +# 'height': int(page_height), +# 'isMobile': is_mobile, +# } +# userAgent = ( +# "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 \ +# (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36" +# ) +# emulate_options = { +# 'viewport': viewport, +# 'userAgent': userAgent +# } + +# # setting device type +# if self.configs.get('device') == 'mobile': +# await page.emulate(emulate_options) +# else: +# await page.setViewport(viewport) + +# # requesting page_url +# await page.goto(_page.page_url, page_options) + +# # handling anamations +# if self.configs.get('disable_animations') == True: +# try: +# # inserting animation pausing script +# await page.evaluate(self.pause_animations_script) +# except: +# print('cannot pause animations') +# try: +# # pausing videos +# videos = await page.querySelectorAll('video') +# for vid in videos: +# await page.evaluate('(vid) => vid.pause()', vid) +# except Exception as e: +# print(e) + +# # mask all listed ids +# if self.configs.get('mask_ids') is not None and self.configs.get('mask_ids') != '': +# ids = self.configs.get('mask_ids').split(',') +# for id in ids: +# try: +# await page.evaluate(f"document.getElementById('{id}').style.visibility='hidden';") +# print('masked an element') +# except: +# print('cannot find element via id provided') + + +# # mask all Global mask ids that are active +# @sync_to_async +# def get_active_global_masks(): +# masks = Mask.objects.filter(active=True) +# active_masks = [] +# if len(masks) > 0: +# for mask in masks: +# active_masks.append(mask.id) +# return active_masks + +# active_masks = await get_active_global_masks() + +# for mask in active_masks: +# try: +# await page.evaluate(f"document.getElementById('{mask}').style.visibility='hidden';") +# print('masked an element') +# except: +# print('cannot find element via global mask id provided') + +# @sync_to_async +# def save_image(*args, **kwargs): +# self.save_image(pic_id=pic_id, image=final_img) + +# # scroll one frame at a time and capture screenshot +# final_img = None +# index = 0 +# last_height = -1 +# bottom = False +# start_time = datetime.now() +# while not bottom: + +# # checking if maxed out time +# if self.check_timeout(int(self.configs.get('timeout', 300)), start_time): +# break + +# # scroll single frame +# if index != 0: +# await page.evaluate("window.scrollBy(0, document.documentElement.clientHeight);") +# time.sleep(int(self.configs.get('min_wait_time', 10))) + +# # get current position and compare to previous +# new_height = await page.evaluate("window.pageYOffset + document.documentElement.clientHeight") +# height_diff = new_height - last_height +# if height_diff > 20: +# last_height = new_height +# pic_id = uuid.uuid4() + +# # interact with and wait for page to load +# await page.mouse.move(0, 0) +# await page.mouse.move(0, 100) +# time.sleep(int(self.configs.get('min_wait_time', 10))) +# await wait_for_page(page=page) + +# # get screenshot +# await page.screenshot({'path': f'{pic_id}.png'}) +# image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') + +# # resizing image to remove duplicate portions +# img = I.open(image) +# width, height = img.size +# left = 0 +# top = height - (height_diff) +# right = width +# _bottom = height +# new_img = img.crop((left, top, right, _bottom)) +# new_img.save(image, quality=100) + +# # adding new image to bottom of existing image (if not index = 0) +# pic_id_2 = uuid.uuid4() +# if index != 0 and final_img is not None: +# self.add_images(final_img, image).save(f'{pic_id_2}.png') +# os.remove(final_img) +# final_img = os.path.join(settings.BASE_DIR, f'{pic_id_2}.png') +# else: +# I.open(image).save(f'{pic_id_2}.png') +# final_img = os.path.join(settings.BASE_DIR, f'{pic_id_2}.png') + +# os.remove(image) +# index += 1 + +# else: +# bottom = True + +# # saving image +# await save_image(pic_id=pic_id, image=final_img) + +# # cleaning up +# await driver.close() + +# # returning images +# return self.image_array + + + + +# def test(self, test: object, index: int=None) -> dict: +# """ +# Compares each screenshot between the two scans and records +# a score out of 100%. + +# Compairsons used : +# - Structral Similarity Index (ssim) +# - PIL ImageChop Differences, Ratio +# - cv2 ORB Brute-force Matcher, Ratio + +# Expects: { +# 'test': object, +# 'index': int, +# } + +# Returns -> data: { +# 'average_score' : float(0-100), +# 'images' : dict, +# } +# """ + +# # setup temp dirs +# if not os.path.exists(os.path.join(settings.BASE_DIR, f'temp/{test.id}')): +# os.makedirs(os.path.join(settings.BASE_DIR, f'temp/{test.id}')) + +# # temp root +# temp_root = os.path.join(settings.BASE_DIR, f'temp/{test.id}') + +# # loop through and download each img in scan and compare it. +# pre_scan_images = test.pre_scan.images +# img_test_results = [] +# scores = [] +# i = 0 + +# if index is not None: +# pre_scan_images = [test.pre_scan.images[index]] +# i = index + +# # catching user error when scan_type +# # did not include 'vrt' +# if pre_scan_images is None: +# images_delta = { +# "average_score": None, +# "images": None, +# } +# return images_delta + + +# for pre_img_obj in pre_scan_images: + +# # getting pre_scan image +# pre_img_path = os.path.join(temp_root, f'{pre_img_obj["id"]}.png') +# with open(pre_img_path, 'wb') as data: +# self.s3.download_fileobj(str(settings.AWS_STORAGE_BUCKET_NAME), pre_img_obj["path"], data) + +# # getting post_scan image +# try: +# post_img_obj = test.post_scan.images[i] +# except: +# post_img_obj = None + +# if post_img_obj is not None: +# post_img_path = os.path.join(temp_root, f'{post_img_obj["id"]}.png') +# with open(post_img_path, 'wb') as data: +# self.s3.download_fileobj(str(settings.AWS_STORAGE_BUCKET_NAME), post_img_obj["path"], data) + +# # open images with PIL Image library +# post_img = I.open(post_img_path) +# pre_img = I.open(pre_img_path) + +# # check and reformat image sizes if necessary +# pre_img_w, pre_img_h = pre_img.size +# post_img_w, post_img_h = post_img.size + +# # pre_img is longer +# if pre_img_h > post_img_h: +# print(f'pre_img is larger, adjusting...') +# new_pre_img = pre_img.crop((0, 0, pre_img_w, post_img_h)).convert(mode=post_img.mode) +# new_pre_img.save(pre_img_path, quality=100) +# pre_img = I.open(pre_img_path) +# # post_img is longer +# if post_img_h > pre_img_h: +# print(f'post_img is larger, adjusting...') +# new_post_img = post_img.crop((0, 0, post_img_w, pre_img_h)).convert(mode=pre_img.mode) +# new_post_img.save(post_img_path, quality=100) +# post_img = I.open(post_img_path) + + +# # build two new images with differences highlighted +# def highlight_diffs(pre_img_path, post_img_path, index): +# ''' +# Returns -> two new images with highlights & float(ssim_score) +# ''' +# # Load the images +# image1 = cv2.imread(pre_img_path) +# image2 = cv2.imread(post_img_path) + +# # Convert the images to grayscale +# gray1 = cv2.cvtColor(image1, cv2.COLOR_BGR2GRAY) +# gray2 = cv2.cvtColor(image2, cv2.COLOR_BGR2GRAY) + +# # Compute the SSIM map +# (ssim_score, diff) = structural_similarity(gray1, gray2, full=True) + +# # Highlight the differences +# diff = (diff * 255).astype("uint8") + +# # Threshold the difference map +# _, thresh = cv2.threshold(diff, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) + +# # Find contours of the differences +# contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + +# # Draw rectangles around the differences +# for contour in contours: +# (x, y, w, h) = cv2.boundingRect(contour) +# cv2.rectangle(image1, (x, y), (x+w, y+h), (0, 255, 0), 2) +# cv2.rectangle(image2, (x, y), (x+w, y+h), (0, 255, 0), 2) + +# # Save the output images +# img_1_id = uuid.uuid4() +# img_2_id = uuid.uuid4() +# cv2.imwrite(temp_root + f"/{img_1_id}.png", image1) +# cv2.imwrite(temp_root + f"/{img_2_id}.png", image2) +# img_objs = save_images(img_1_id, img_2_id, index) + +# data = { +# "img_objs": img_objs, +# "ssim_score": ssim_score +# } + +# return data + + +# # saving old images to new test.id path +# def save_images(pre_img_id, post_img_id, index): +# image_ids = [pre_img_id, post_img_id] +# img_objs = [] +# for img_id in image_ids: +# image = os.path.join(temp_root, f'{img_id}.png') +# remote_path = f'static/sites/{test.page.site.id}/{test.page.id}/{test.id}/{img_id}.png' +# root_path = settings.AWS_S3_URL_PATH +# image_url = f'{root_path}/{remote_path}' + +# # upload to s3 +# with open(image, 'rb') as data: +# self.s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), +# remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} +# ) + +# # building img obj +# obj = { +# "id": str(img_id), +# "url": image_url, +# "path": remote_path, +# "index": index, +# } +# img_objs.append(obj) + +# return img_objs + + +# # test images with PIL +# def pil_score(pre_img, post_img): +# try: +# if (pre_img.mode != post_img.mode) \ +# or (pre_img.size != post_img.size) \ +# or (pre_img.getbands() != post_img.getbands()): +# raise Exception('images are not comparable') + +# # Generate diff image in memory. +# diff_img = ImageChops.difference(pre_img, post_img) + +# # Calculate difference as a ratio. +# stat = ImageStat.Stat(diff_img) +# diff_ratio = (sum(stat.mean) / (len(stat.mean) * 255)) * 100 +# pil_img_score = (100 - diff_ratio) +# # print(f'PIL score -> {pil_img_score}') +# return pil_img_score + +# except Exception as e: +# print(e) + + +# # test with cv2 +# def cv2_score(pre_img, post_img): +# try: +# orb = cv2.ORB_create() + +# # convert to array +# pre_img_array = numpy.array(pre_img) +# post_img_array = numpy.array(post_img) + +# # detect keypoints and descriptors +# kp_a, desc_a = orb.detectAndCompute(pre_img_array, None) +# kp_b, desc_b = orb.detectAndCompute(post_img_array, None) + +# # define the bruteforce matcher object +# bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) + +# # perform matches. +# matches = bf.match(desc_a, desc_b) + +# # Look for similar regions with distance < 20. (from 0 to 100) +# similar_regions = [i for i in matches if i.distance < 20] +# if len(matches) == 0: +# cv2_img_score = 100 +# else: +# cv2_img_score = (len(similar_regions) / len(matches)) * 100 +# # print(f'cv2 -> {cv2_img_score}') + +# return cv2_img_score + +# except Exception as e: +# print(e) + + + +# # test images +# try: +# # generating new highlighted images and score via ssim +# ssim_results = highlight_diffs(pre_img_path, post_img_path, i) +# pre_img_diff = ssim_results['img_objs'][0] +# post_img_diff = ssim_results['img_objs'][1] + +# # ssim scoring +# ssim_img_score = ssim_results['ssim_score'] * 100 + +# # pillow scoring +# pil_img_score = pil_score(pre_img, post_img) + +# # pixel perfect scoring +# cv2_img_score = cv2_score(pre_img, post_img) + +# # weighted average +# img_score = ((ssim_img_score * 2) + (pil_img_score * 1) + (cv2_img_score * 5)) / 8 + +# # saving old images to test.id path +# old_imgs = save_images(pre_img_obj['id'], post_img_obj['id'], i) +# pre_img = old_imgs[0] +# post_img = old_imgs[1] + +# except Exception as e: +# print(e) +# img_score = None +# pre_img = None +# post_img = None +# pre_img_diff = None +# post_img_diff = None + +# # create img test obj and add to array +# img_test_obj = { +# "index": i, +# "pre_img": pre_img, +# "post_img": post_img, +# "pre_img_diff": pre_img_diff, +# "post_img_diff": post_img_diff, +# "score": img_score, +# } + +# img_test_results.append(img_test_obj) +# scores.append(img_score) + +# # remove local copies +# if post_img_obj is not None: +# try: +# os.remove(post_img_path) +# except Exception as e: +# print(e) +# try: +# os.remove(pre_img_path) +# except Exception as e: +# print(e) + +# i += 1 + +# # remove temp dir +# shutil.rmtree(temp_root) + +# # averaging scores and storing in images_delta obj +# try: +# avg_score = statistics.fmean(scores) +# except: +# avg_score = None + +# # formatting response +# images_delta = { +# "average_score": avg_score, +# "images": img_test_results, +# } + +# # returning response +# return images_delta + + + + + diff --git a/app/api/utils/archive/wordpress_p.py b/app/api/utils/archive/wordpress_p.py new file mode 100644 index 00000000..2b53dce7 --- /dev/null +++ b/app/api/utils/archive/wordpress_p.py @@ -0,0 +1,570 @@ +# from .driver_p import driver_init +# import time, asyncio, uuid +# from ..models import * +# from datetime import datetime +# from asgiref.sync import sync_to_async + + + + + + +# class Wordpress(): + + +# def __init__( +# self, +# login_url, +# admin_url, +# username, +# password, +# email_address, +# destination_url, +# sftp_address, +# dbname, +# sftp_username, +# sftp_password, +# wait_time, +# process_id +# ): +# # set all global vars +# self.login_url = login_url +# self.username = username +# self.password = password +# self.email_address = email_address +# self.destination_url = destination_url +# self.sftp_address = sftp_address +# self.dbname = dbname +# self.sftp_username = sftp_username +# self.sftp_password = sftp_password +# self.process = Process.objects.get(id=process_id) +# self.native_lang = 'en' + +# if not admin_url.endswith('/'): +# admin_url = admin_url + '/' +# self.admin_url = admin_url + +# if wait_time is None: +# self.wait_time = 30 +# else: +# self.wait_time = wait_time + +# self.navWaitOpt = { +# 'timeout': self.wait_time * 1000, +# 'waitUntil': 'domcontentloaded' +# } + + +# async def login(self): + +# ''' +# Tries to log into a WP site with given credentials. + +# returns --> True / False + +# ''' + +# print('begining login method for ' + self.login_url) + + +# self.driver = await driver_init(wait_time=self.wait_time) + +# # init page obj +# self.page = await self.driver.newPage() +# page_options = { +# 'waitUntil': 'networkidle0', +# 'timeout': self.wait_time * 1000 +# } + +# try: +# await self.page.goto(self.login_url, page_options) +# try: +# await self.page.xpath('//*[@id="user_login"]') +# print('found login form') +# except: +# try: +# jetpack = await self.page.xpath('//*[@id="jetpack-sso-wrap"]/a[1]') +# await jetpack[0].click() +# await self.page.xpath('//*[@id="user_login"]') +# print('found login form') +# except: +# try: +# login_link = await self.page.xpath("//a[contains(., 'Login with username and password')]") +# await login_link[0].click() +# await self.page.xpath('//*[@id="user_login"]') +# print('found login form') +# except: +# print('unable to locate login form at this path') +# await self.driver.close() +# return False + + +# except: +# print('unable to locate login form at this path') +# await self.driver.close() +# return False + +# user_name_elem = await self.page.xpath('//*[@id="user_login"]') +# await user_name_elem[0].click(clickCount=3) +# await self.page.keyboard.type(self.username) +# time.sleep(1) +# passworword_elem = await self.page.xpath('//*[@id="user_pass"]') +# await passworword_elem[0].click(clickCount=3) +# await self.page.keyboard.type(self.password) +# time.sleep(1) +# await self.page.keyboard.press('Enter') +# await self.page.waitForNavigation(self.navWaitOpt) + + +# try: +# try: +# verify_email = await self.page.xpath('//*[@id="correct-admin-email"]') +# print('need to verify email') +# await verify_email[0].click() +# print('clicked verify') +# except: +# pass + +# print('done with login attempt') + +# try: +# await self.page.xpath('//*[@id="login_error"]') +# print('found login error') +# await self.page.reload() + +# print('trying login again') +# user_name_elem = await self.page.xpath('//*[@id="user_login"]') +# await user_name_elem[0].click(clickCount=3) +# await self.page.keyboard.type(self.username) +# time.sleep(1) +# passworword_elem = await self.page.xpath('//*[@id="user_pass"]') +# await passworword_elem[0].click(clickCount=3) +# await self.page.keyboard.type(self.password) +# time.sleep(1) +# await self.page.keyboard.press('Enter') +# await self.page.waitForNavigation(self.navWaitOpt) + + +# try: +# await self.page.xpath('//*[@id="login_error"]') +# print('found login error again') +# print('counld not login to this site') +# except: +# print('no login errors') + +# except: +# print('no login errors') + +# except: +# print('counld not login to this site') + +# await self.driver.close() +# return False + + +# # removing alerts +# try: +# deny_btn = await self.page.xpath('//*[@id="webpushr-deny-button"]') +# await deny_btn[0].click() +# print('removed alert') +# except: +# pass +# try: +# # checking if url location is wp-admin +# admin_link = '/wp-admin/' +# current_url = self.page.url +# print('current url -> ' + current_url) +# if current_url.endswith("/wp-admin") or current_url.endswith("/wp-admin/") or admin_link in current_url: +# print('inside wp-admin') +# else: +# print('not in wp-admin - navigating there now') +# admin_btn = await self.page.xpath('//*[@id="wp-admin-bar-dashboard"]') +# admin_link = await admin_btn[0].querySelector('a') +# await admin_link[0].click(clickCount=2) +# print('clicked dashboard link') +# await self.page.waitForNavigation(self.navWaitOpt) + + +# except: +# print('could not login') +# await self.driver.close() +# return False + + +# return True + + + + + +# async def begin_lang_check(self): + +# try: +# # navigate to settings +# s_url = 'options-general.php' +# try: +# settings_menu = await self.page.xpath('//*[@id="menu-settings"]') +# await settings_menu[0].click() +# print('clicked settings menu') +# await self.page.waitForNavigation(self.navWaitOpt) +# settings = await self.page.xpath('.//a[@href="'+s_url+'"]') +# await settings[0].click() +# print('clicked settings tab') +# await self.page.waitForNavigation(self.navWaitOpt) + + +# except: +# await self.page.goto(self.page.url + s_url) +# await self.page.waitForNavigation(self.navWaitOpt) + +# # finding and recording current native language +# lang_selector = await self.page.xpath('//*[@id="WPLANG"]') +# optgroup = await lang_selector[0].querySelector('optgroup') +# selected_lang = await optgroup.xpath('.//option[@selected="selected"]') +# default_lang = await (await selected_lang[0].getProperty('lang')).jsonValue() +# default_lang_value = await (await selected_lang[0].getProperty('value')).jsonValue() +# print("defalut lang value is " + str(default_lang)) + +# if default_lang != 'en': + +# # selecting english +# await lang_selector[0].select('en_CA') +# print('selected english') + +# # saving settings +# save_btn = await self.page.xpath('//*[@id="submit"]') +# await save_btn[0].click() +# print('saved lang to english') + +# self.native_lang = default_lang_value +# return True + +# else: +# self.native_lang = 'en' + + +# except: +# print('error in changing language') +# return False + + + + + + +# async def end_lang_check(self): + +# if self.native_lang != 'en': + +# try: +# # navigate to settings +# s_url = 'options-general.php' +# try: +# settings_menu = await self.page.xpath('//*[@id="menu-settings"]') +# await settings_menu[0].click() +# print('clicked settings menu') +# await self.page.waitForNavigation(self.navWaitOpt) +# settings = await self.page.xpath('.//a[@href="'+s_url+'"]') +# await settings[0].click() +# print('clicked settings tab') +# await self.page.waitForNavigation(self.navWaitOpt) + +# except: +# await self.page.goto(self.page.url + s_url) +# await self.page.waitForNavigation(self.navWaitOpt) + +# # selecting native lang +# lang_selector = await self.page.xpath('//*[@id="WPLANG"]') +# await lang_selector[0].select(self.native_lang) +# print('selected native_lang') + +# # saving settings +# save_btn = await self.page.xpath('//*[@id="submit"]') +# await save_btn[0].click() +# print('saved native lang') + +# except: +# await self.driver.close() +# return False + +# await self.driver.close() +# return True + + + +# async def install_plugin(self, plugin_name): + +# # setting url for link naving +# plugin_menu_page = 'plugins.php' +# add_plugin_page = 'plugin-install.php' + +# # navigating to plugin page +# try: +# print('trying click method') +# plugin_menu = await self.page.xpath('//*[@id="menu-plugins"]') +# await plugin_menu[0].click() +# await self.page.waitForNavigation(self.navWaitOpt) +# p_url = 'plugins.php' +# plugins = await self.page.xpath('.//a[@href="'+p_url+'"]') +# await plugins[0].click() +# print('clicked plugin menu') +# await self.page.waitForNavigation(self.navWaitOpt) + + +# # looking for dependencies in plugin table +# time.sleep(10) +# form = await self.page.xpath('//*[@id="bulk-action-form"]') +# pluginTable = await form[0].querySelector('tbody') +# tableText = await (await pluginTable.getProperty('textContent')).jsonValue() + +# except: +# print('trying link method for navigation') +# try: +# await self.page.goto(self.admin_link + plugin_menu_page) +# await self.page.waitForNavigation(self.navWaitOpt) + +# time.sleep(10) +# # looking for dependencies in plugin table +# form = await self.page.xpath('//*[@id="bulk-action-form"]') +# pluginTable = await form[0].querySelector('tbody') +# tableText = await (await pluginTable.getProperty('textContent')).jsonValue() +# except: +# print('unable to find plugin table') +# await self.driver.close() +# return False + +# if plugin_name not in tableText: +# try: +# print('plugin not present, preparing to install') + +# time.sleep(2) +# print('navigating to add plugins page') + +# try: +# url = 'plugin-install.php' +# add_plugin = await self.page.xpath('//a[@href="'+url+'"]') +# await add_plugin[0].click(clickCount=2) +# print('clicked add plugin link') +# await self.page.waitForNavigation(self.navWaitOpt) + +# time.sleep(5) +# except: +# await self.page.goto(self.admin_url + add_plugin_page) +# await self.page.waitForNavigation(self.navWaitOpt) + +# time.sleep(5) + + +# # searching for plugin +# search_form = await self.page.xpath('//input[@type="search"]') +# await search_form[0].click(clickCount=3) +# await self.page.keyboard.type(plugin_name) +# time.sleep(1) +# await self.page.keyboard.press('Enter') +# time.sleep(3) + +# ##### Clicking "install" plugin ###### +# install = await self.page.xpath('//*[@id="the-list"]/div[1]/div[1]/div[2]/ul/li[1]/a') #### ---> This will have to updated regularly +# await install[0].click(clickCount=2) +# print('clicked -install plugin-') +# time.sleep(30) + + +# #### Clicking "activate" plugin ###### +# await self.page.reload() +# print('reloading page') +# try: +# await self.page.waitForNavigation(self.navWaitOpt) +# except: +# pass +# activate = await self.page.xpath('//*[@id="the-list"]/div[1]/div[1]/div[2]/ul/li[1]/a') #### ---> This will have to updated regularly +# await activate[0].click(clickCount=2) +# print('clicked -Activate plugin-') +# time.sleep(30) +# print('Dependencies installed sucessfully') +# return True + +# except: +# print('failed dependency installation') +# await self.driver.close() +# return False + +# else: +# print('plugin already installed') +# return True + + +# @sync_to_async +# def update_process(self, successful=False, info_url=None, time_completed=None, progress=None): +# if info_url is not None: +# self.process.info_url = info_url +# self.process.success = successful +# if time_completed is not None: +# self.process.time_completed = time_completed +# if progress is not None: +# self.process.progress = progress + +# self.process.save() +# return + + + +# async def launch_migration(self): +# ''' +# Launches the migration plugin once Activated. + +# returns --> True / False + +# ''' + +# # setting url for link naving +# migrate_page = 'admin.php?page=cloudways' +# current_url = self.page.url + +# if not current_url.endswith("cloudways"): +# print('navigating to migration page') +# if self.admin_url.endswith('/'): +# await self.page.goto(f'{self.admin_url}{migrate_page}') +# else: +# await self.page.goto(f'{self.admin_url}/{migrate_page}') +# time.sleep(10) + + +# # wait for cloudways email field to become visible +# # entering self.email_address in field +# email = await self.page.xpath('//*[@id="wpbody-content"]/main/div/form/div/input') +# await email[0].click(clickCount=3) +# await self.page.keyboard.type(self.email_address) +# print('entered cloudways email') + +# # checking T&S checbox +# checkbox = await self.page.xpath('//*[@id="wpbody-content"]/main/div/form/div/div/label/input[3]') +# await checkbox[0].click(clickCount=1) +# print('checked T&S agreement') + +# # clicking submit to launch migration plugin +# m_button = await self.page.xpath('//*[@id="migratesubmit"]') +# await m_button[0].click(clickCount=1) +# print('clicked migrate button') + +# return True + + +# async def run_migration(self): +# ''' +# Enters data on migration page, initiates miration +# and begins updating the associated `Process` with data +# from the page. + +# returns --> True / False + +# ''' + +# # check for page to fully load +# print('waiting 10 sec for new page to load') +# time.sleep(10) +# ## enter all necessary data in each field +# await self.page.waitForNavigation(self.navWaitOpt) + +# # get_element_by_name="address" -> self.destination_url +# destination_url = await self.page.xpath('//*[@id="app"]/span/div[2]/div/div/div/div/div/form/div/div[1]/div/div/input[1]') +# await destination_url[0].click(clickCount=3) +# await self.page.keyboard.type(self.destination_url) +# print(f'dest_url as -> {self.destination_url}') +# time.sleep(2) + +# # get_element_by_name="newurl" -> self.sftp_address +# sftp_address = await self.page.xpath('//*[@id="app"]/span/div[2]/div/div/div/div/div/form/div/div[2]/div/div/input[1]') +# await sftp_address[0].click(clickCount=3) +# await self.page.keyboard.type(self.sftp_address) +# print(f'sftp_address as -> {self.sftp_address}') +# time.sleep(2) + +# # get_element_by_name="appfolder" -> self.dbname +# dbname = await self.page.xpath('//*[@id="app"]/span/div[2]/div/div/div/div/div/form/div/div[3]/div/div/input[1]') +# await dbname[0].click(clickCount=3) +# await self.page.keyboard.type(self.dbname) +# print(f'dbname as -> {self.dbname}') +# time.sleep(2) + +# # get_element_by_name="username" -> self.sftp_username +# sftp_username = await self.page.xpath('//*[@id="app"]/span/div[2]/div/div/div/div/div/form/div/div[4]/div/div/input[1]') +# await sftp_username[0].click(clickCount=3) +# await self.page.keyboard.type(self.sftp_username) +# print(f'sftp_username as -> {self.sftp_username}') +# time.sleep(2) + +# # get_element_by_name="passwd" -> self.sftp_password +# sftp_password = await self.page.xpath('//*[@id="app"]/span/div[2]/div/div/div/div/div/form/div/div[5]/div/div/input[1]') +# await sftp_password[0].click(clickCount=3) +# await self.page.keyboard.type(self.sftp_password) +# print(f'sftp_password as -> {self.sftp_password}') +# time.sleep(2) + +# print('entered all creds') + +# # submit data +# await self.page.keyboard.press('Enter') +# print('pressed enter key') + + + +# # update self.process with info_url +# info_url = self.page.url +# await self.update_process(info_url=info_url) + + +# done = False +# done_text = 'Your migration is complete!' +# new_progress = 0 +# print(f'current url -> {self.page.url}') +# while not done: + +# # checking for progres bar +# try: +# raw_progress = await self.page.xpath('//*[@id="app"]/span/div[2]/span/div/div/div/div/div/div[3]/div[4]/div[2]') +# new_progress = await (await raw_progress[0].getProperty('textContent')).jsonValue() +# new_progress = float(new_progress.split('%')[0]) +# except Exception as e: +# # print(e) +# pass + + +# # update self.process +# await self.update_process(progress=new_progress) + +# # check if new_progress is 100% +# page_content = await self.page.content() +# if new_progress >= 100 or done_text in page_content: +# time_completed = datetime.now() +# await self.update_process(successful=True, time_completed=time_completed, progress=100) +# done = True + +# # checking for process errors +# if 'alert alert-danger' in page_content: +# done = True +# time_completed = datetime.now() +# await self.update_process(time_completed=time_completed) +# print('found an error - ending process') +# return False + +# time.sleep(1) + + +# return True + + + + + + +# async def run_full(self, plugin_name): +# data = await self.login() +# data = await self.begin_lang_check() +# data = await self.install_plugin(plugin_name) +# # data = await self.end_lang_check() +# data = await self.launch_migration() +# data = await self.run_migration() +# await self.driver.close() +# return data + diff --git a/app/api/utils/autocaser.py b/app/api/utils/autocaser.py new file mode 100644 index 00000000..1c504def --- /dev/null +++ b/app/api/utils/autocaser.py @@ -0,0 +1,1143 @@ +from .driver import driver_init, driver_wait, quit_driver +from selenium.webdriver.common.by import By +from ..models import Site, Case +from cursion import settings +import time, os, json, uuid, random, boto3 + + + + + + +class AutoCaser(): + """ + Generate new `Cases` for the passed 'site'. + + Expects: { + 'site' : object, + 'process' : object, + 'start_url' : str, + 'configs' : dict, + 'max_cases' : int, + 'max_layers' : int + } + + Use `AutoCaser.build_cases()` to generate new `Cases` + + Returns -> None + """ + + + def __init__( + self, + site : object, + process : object, + start_url : str=None, + configs : dict=settings.CONFIGS, + max_cases : int=4, + max_layers : int=5, + ): + + # main objects & configs + self.site = site + self.process = process + self.start_url = start_url + self.configs = configs + self.max_cases = max_cases + self.max_layers = max_layers + + # high-level elemets array. + # All elememts represent the + # begining of a new Case. + self.elements = [] + self.final_start_elements = [] + + # starting driver + self.driver = driver_init( + window_size=self.configs.get('window_size'), + device=self.configs.get('device'), + ) + + # setting selector script + self.selector_script = ( + """ + const getSelector = (elm) => { + if (elm.tagName === "BODY") return "BODY"; + const names = []; + while (elm.parentElement && elm.tagName !== "BODY") { + if (elm.id) { + names.unshift(`[id='${elm.getAttribute("id")}']`); // "#" + elm.getAttribute("id") + break; + } else { + let c = 1, e = elm; + for (; e.previousElementSibling; e = e.previousElementSibling, c++) ; + names.unshift(elm.tagName + ":nth-child(" + c + ")"); + } + elm = elm.parentElement; + } + return names.join(">"); + } + + return getSelector(arguments[0]) + + """ + ) + + # setting xpath script + self.xpath_script = ( + """ + const getXPath = (elm) => { + const idx = (sib, name) => sib + ? idx(sib.previousElementSibling, name||sib.localName) + (sib.localName == name) + : 1; + const segs = elm => !elm || elm.nodeType !== 1 + ? [''] + : elm.id && document.getElementById(elm.id) === elm + ? [`id("${elm.id}")`] + : [...segs(elm.parentNode), `${elm.localName.toLowerCase()}[${idx(elm)}]`]; + return segs(elm).join('/'); + } + + return getXPath(arguments[0]) + + """ + ) + + # setting selector script + self.visible_script = ( + """ + const isVisible = (elm) => { + try{ + if (window.getComputedStyle(elm).visibility === 'hidden' || window.getComputedStyle(elm).display === 'none'){ + return false + } else { + return true + } + }catch{ + return false + } + } + + return isVisible(arguments[0]) + + """ + ) + + # setting defaults for inputs + self.input_types = { + "button": {'test_data': None, 'action': 'click'}, + "checkbox": {'test_data': None, 'action': 'click'}, + "color": {'test_data': '#ff0000', 'action': 'change'}, + "date": {'test_data': '2024-04-23', 'action': 'change'}, + "datetime-local": {'test_data': '2024-04-22T12:49', 'action': 'change'}, + "email": {'test_data': 'jane@example.com', 'action': 'change'}, + "file": {'test_data': None, 'action': None}, + "hidden": {'test_data': None, 'action': None}, + "image": {'test_data': None, 'action': None}, + "month": {'test_data': '2024-04', 'action': 'change'}, + "number": {'test_data': '1', 'action': 'change'}, + "password": {'test_data': 'pass123456!@', 'action': 'change'}, + "radio": {'test_data': None, 'action': 'click'}, + "range": {'test_data': 1, 'action': 'change'}, + "reset": {'test_data': None, 'action': None}, + "search": {'test_data': 'search example', 'action': 'change'}, + "submit": {'test_data': None, 'action': 'click'}, + "tel": {'test_data': '5555555555', 'action': 'change'}, + "text": {'test_data': 'Example Text', 'action': 'change'}, + "time": {'test_data': '12:34', 'action': 'change'}, + "url": {'test_data': 'https://example.com', 'action': 'change'}, + "week": {'test_data': '2024-W15', 'action': 'change'}, + "textarea": {'test_data': 'This is longer example text for testing.', 'action': 'change'}, + "None": {'test_data': None, 'action': None}, + } + + # setting blacklist for input types to ignore + self.blacklist = ['file', 'hidden', 'image', 'reset'] + + # setup boto3 configurations + self.s3 = boto3.client( + 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), + aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), + region_name=str(settings.AWS_S3_REGION_NAME), + endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) + ) + + + + + def update_process( + self, + current: int, + total: int, + complete: bool=False, + exception: str=None + ) -> None: + # calculate the current progress of the + # task based on current iteration and total + # iterations expected + final_progress = 90 + progress = 0 + success = False + if complete: + progress = 100 + success = True + if not complete: + progress = float((current/total) * final_progress) + + print(f'updating process --> {progress}%') + + # update Process obj + self.process.progress = progress + self.process.success = success + self.process.save() + + + + + def is_element_visible(self, element: object) -> bool: + try: + resp = self.driver.execute_script(self.visible_script, element) + resp = str(resp).lower() + if resp == 'true': + return True + if resp == 'false': + return False + except Exception as e: + print(f'is_element_visible() Exception -> Stale element reference') + + + + + def get_element_image(self, element: object) -> str: + try: + image = element.screenshot_as_base64 + # sleep for .5 seconds to let image process + time.sleep(.5) + except: + image = None + return image + + + + + def get_url_root(self, url: str) -> str: + protocol = url.split('//')[0] + '//' + root_url = protocol + url.split('//')[1].split('/')[0] + return root_url + + + + + def get_relative_url(self, url: str) -> str: + relative_url = '/' + url.split('//')[1].split('/')[1] + return relative_url + + + + + def get_elem_text(self, selector: str) -> str: + elem_text = self.driver.execute_script(f'return document.querySelector("{selector}").innerText') + elem_text = elem_text.split('\n')[0].strip() + return elem_text + + + + + def get_priority_elements(self, elements: list) -> dict: + priority_words = [ + 'cart', 'checkout', 'add to cart', 'add to the cart', + 'add to basket', 'add to shopping basket', 'add to shopping cart', + 'add to the cart', 'billing', 'address', 'payment', 'purchase now', + 'order now', 'order', 'shop now', 'continue to payment', 'contact', + 'apply', 'submit', 'contact sales', 'contact us' + ] + + priority_elements = [] + non_priority_elements = [] + + # checking each element for prioriry words + for element in elements: + + # get element's innerText + elem_selector = self.driver.execute_script(self.selector_script, element) + elm_text = self.driver.execute_script(f'return document.querySelector("{elem_selector}").innerText') + + # check each priority word against element innerText + for word in priority_words: + if word in elm_text.lower() or elm_text.lower() in word: + priority_elements.append(element) + break + elif element not in non_priority_elements: + non_priority_elements.append(element) + + # if priotity_elements[] is empty + # look for any forms and add them + if len(priority_elements) == 0: + for element in elements: + if element.tag_name == 'form': + # add to priority + priority_elements.append(element) + print('added FORM to priority_elements[]') + + data = { + 'priority_elements': priority_elements, + 'non_priority_elements': non_priority_elements + } + + return data + + + + + def get_current_elements(self) -> list: + # returns a list of interactable + # elements on the current page and + # removes and duplicates before returning + buttons = self.driver.find_elements(By.TAG_NAME, 'button') + links = self.driver.find_elements(By.TAG_NAME, 'a') + forms = self.driver.find_elements(By.TAG_NAME, 'form') + inputs = self.driver.find_elements(By.TAG_NAME, 'input') + textareas = self.driver.find_elements(By.TAG_NAME, 'textarea') + inputs_textareas_buttons = inputs + textareas + buttons + + # get all form inputs, textareas, & buttons + form_elems = [] + for form in forms: + # form inputs + form_inputs = form.find_elements(By.TAG_NAME, 'input') + form_elems += form_inputs + # form textarea + form_textares = form.find_elements(By.TAG_NAME, 'textarea') + form_elems += form_textares + # form buttons + form_buttons = form.find_elements(By.TAG_NAME, 'button') + form_elems += form_buttons + + # then remove duplicates + inputs_textareas_buttons = [elem for elem in inputs_textareas_buttons if elem not in form_elems] + + # shuffle elements in place + random.shuffle(forms) + random.shuffle(inputs_textareas_buttons) + random.shuffle(links) + + current_elements = forms + inputs_textareas_buttons + links + + # return result + return current_elements + + + + + def check_for_duplicates(self, selector: str, elements: list=None) -> bool: + found_duplicate = False + if elements is None: + elements = self.elements + + # checking against all final start elements + for final_start_elem in self.final_start_elements: + if final_start_elem == selector: + found_duplicate = True + return found_duplicate + + for elem in elements: + # check if selector exists already + if elem['selector'] == selector: + found_duplicate = True + break + + # check if sub_elements exists + if elem['elements'] != None: + self.check_for_duplicates(selector=selector, elements=elem['elements']) + + # return result + return found_duplicate + + + + + def get_clean_elements(self, elements: list, check_against: list=None) -> list: + cleaned_elements = [] + current_url = self.driver.current_url + + for elem in elements: + # get slector + elem_selector = self.driver.execute_script(self.selector_script, elem) + + # check local duplicates + if check_against is not None: + if self.check_for_duplicates(selector=elem_selector, elements=check_against): + print(f'found local duplicate => {elem_selector}') + continue + + # check global duplicates + if self.check_for_duplicates(selector=elem_selector): + print(f'found global duplicate => {elem_selector}') + continue + + # check url if + if elem.tag_name == 'a': + # check if action will reload page or site root + elem_link = elem.get_attribute('href') + # check it elem_link is blank + if elem_link is None: + print('elem_link not present') + continue + if current_url == elem_link or elem_link == self.site.site_url or elem_link == '/': + print('elem reloads page') + continue + # check if action will nav to new site + if not elem_link.startswith(self.site.site_url): + print(f'elem links to different site') + continue + + # add to cleaned conditions passed + cleaned_elements.append(elem) + + # return cleaned elements + return cleaned_elements + + + + + def record_new_element(self, elem: object, sub_elements: list) -> dict: + """ + returns -> { + 'sub_elements': [], + 'run': bool, + 'added': bool, + } + """ + # setting defaults + run = True + added = False + + # check if element is visible + if not self.is_element_visible(elem): + data = { + 'run': run, + 'added': added, + 'sub_elements': sub_elements + } + return data + + # get sub element info + elem_selector = self.driver.execute_script(self.selector_script, elem) + elem_xpath = self.driver.execute_script(self.xpath_script, elem) + elem_img = self.get_element_image(element=elem) + relative_url = self.get_relative_url(self.driver.current_url) + + # found new element, record, click, & continue + if elem.tag_name == 'a' or elem.tag_name == 'button': + + # record element + sub_elements.append({ + 'selector': elem_selector, + 'xpath': elem_xpath, + 'elem_type': elem.tag_name, + 'placeholder': None, + 'value': None, + 'type': None, + 'data': None, + 'action': 'click', + 'path': relative_url, + 'img': elem_img, + 'elements': None, + }) + + # click element + try: + elem.click() + except Exception as e: + print('Element not Clickable, removing') + sub_elements.pop() + + # add to layers and ending internal loop + added = True + run = True + + + # found new input or textarea + elif elem.tag_name == 'input' or elem.tag_name == 'textarea': + + # getting element values and type + type = str(elem.get_attribute('type')) + value = elem.get_attribute('value') + if elem.tag_name == 'textarea': + type = 'textarea' + + # record element + sub_elements.append({ + 'selector': elem_selector, + 'xpath': elem_xpath, + 'elem_type': elem.tag_name, + 'placeholder': elem.get_attribute('placeholder'), + 'value': value, + 'type': type, + 'data': self.input_types[type]['test_data'], + 'action': self.input_types[type]['action'], + 'path': relative_url, + 'img': elem_img, + 'elements': None, + }) + + # add to layers and ending internal loop + added = True + run = True + + + # found new form, record and end run + elif elem.tag_name == 'form': + + # record form into sub_elements list + sub_elements = self.record_forms( + elements=sub_elements, + form=elem + ) + + # add to layers and ending case + added = True + run = False + + data = { + 'sub_elements': sub_elements, + 'run': run, + 'added': added + } + + return data + + + + + def record_forms(self, elements: list, form: object=None) -> list: + + # wait for page to load + driver_wait( + driver=self.driver, + interval=self.configs.get('interval'), + max_wait_time=self.configs.get('max_wait_time'), + min_wait_time=self.configs.get('min_wait_time'), + ) + + # building forms list + if form is None: + # get all forms on the page + forms = self.driver.find_elements(By.TAG_NAME, "form") + else: + # adding single form to que + forms = [form] + + # begin iteration of
gathering + for form in forms: + + # get form selector & xpath + form_selector = self.driver.execute_script(self.selector_script, form) + form_xpath = self.driver.execute_script(self.xpath_script, form) + + print(f'recording form -> {form_selector}') + + # getting form text + elem_text = self.get_elem_text(selector=form_selector) + + # get form image + form_img = self.get_element_image(element=form) + + # defining form.elements + sub_elements = [] + + # get all input fields in form + inputs = form.find_elements(By.TAG_NAME, "input") + # iterate through each input + for i in inputs: + + if i.get_attribute('type') not in self.blacklist and self.is_element_visible(i): + # get input data + input_selector = self.driver.execute_script(self.selector_script, i) + input_xpath = self.driver.execute_script(self.xpath_script, i) + placeholder = i.get_attribute('placeholder') + value = i.get_attribute('value') + type = str(i.get_attribute('type')) + img = self.get_element_image(element=i) + relative_url = self.get_relative_url(self.driver.current_url) + + sub_elements.append({ + 'selector': input_selector, + 'xpath': input_xpath, + 'elem_type': i.tag_name, + 'placeholder': placeholder, + 'value': value, + 'type': type, + 'data': self.input_types[type]['test_data'], + 'action': self.input_types[type]['action'], + 'path': relative_url, + 'img': img, + 'elements': None, + }) + + + # get all textarea fields in form + textareas = form.find_elements(By.TAG_NAME, "textarea") + # iterate through each input + for i in textareas: + + if i.get_attribute('type') not in self.blacklist and self.is_element_visible(i): + # get input data + input_selector = self.driver.execute_script(self.selector_script, i) + input_xpath = self.driver.execute_script(self.xpath_script, i) + placeholder = i.get_attribute('placeholder') + type = str(i.get_attribute('type')) + img = self.get_element_image(element=i) + relative_url = self.get_relative_url(self.driver.current_url) + + sub_elements.append({ + 'selector': input_selector, + 'xpath': input_xpath, + 'elem_type': i.tag_name, + 'placeholder': placeholder, + 'value': None, + 'type': type, + 'data': self.input_types['textarea']['test_data'], + 'action': self.input_types['textarea']['action'], + 'path': relative_url, + 'img': img, + 'elements': None, + }) + + + # get all iframes elements in form + iframes = form.find_elements(By.TAG_NAME, "iframe") + # iterate through iframes and save data + for iframe in iframes: + + # get iframe data + iframe_selector = self.driver.execute_script(self.selector_script, iframe) + iframe_xpath = self.driver.execute_script(self.xpath_script, iframe) + iframe_img = self.get_element_image(element=iframe) + relative_url = self.get_relative_url(self.driver.current_url) + + # get all inputs for iframe + iframe_inputs = iframe.find_elements(By.TAG_NAME, "input") + + # iterate through each input + iframe_elements = [] + for i in iframe_inputs: + + if i.get_attribute('type') not in self.blacklist and self.is_element_visible(i): + # get input data + input_selector = self.driver.execute_script(self.selector_script, i) + input_xpath = self.driver.execute_script(self.selector_script, i) + placeholder = i.get_attribute('placeholder') + value = i.get_attribute('value') + type = str(i.get_attribute('type')) + img = self.get_element_image(element=i) + relative_url = self.get_relative_url(self.driver.current_url) + + # save internal iframe data + iframe_elements.append({ + 'selector': input_selector, + 'xpath': input_xpath, + 'elem_type': i.tag_name, + 'placeholder': placeholder, + 'value': value, + 'type': type, + 'data': self.input_types[type]['test_data'], + 'action': self.input_types[type]['action'], + 'path': relative_url, + 'img': img, + 'elements': None, + }) + + # save sub elem data + sub_elements.append({ + 'selector': iframe_selector, + 'xpath': iframe_xpath, + 'elem_type': iframe.tag_name, + 'placeholder': None, + 'value': None, + 'type': None, + 'data': None, + 'action': 'switch_to_frame', + 'path': relative_url, + 'img': iframe_img, + 'elements': iframe_elements, + }) + + + # get all button elements in form + btns = form.find_elements(By.TAG_NAME, "button") + # iterate through each btn + for btn in btns: + + if self.is_element_visible(btn): + # get button data + btn_selector = self.driver.execute_script(self.selector_script, btn) + btn_xpath = self.driver.execute_script(self.xpath_script, btn) + type = str(btn.get_attribute('type')) + btn_img = self.get_element_image(element=btn) + relative_url = self.get_relative_url(self.driver.current_url) + + sub_elements.append({ + 'selector': btn_selector, + 'xpath': btn_xpath, + 'elem_type': 'button', + 'placeholder': None, + 'value': None, + 'type': type, + 'data': None, + 'elements': None, + 'action': 'click', + 'path': relative_url, + 'img': btn_img, + 'elements': None, + }) + + # save elem data + elements.append({ + 'selector': form_selector, + 'xpath': form_xpath, + 'elem_type': 'form', + 'elem_text': elem_text, + 'value': None, + 'type': None, + 'data': None, + 'action': None, + 'path': relative_url, + 'img': form_img, + 'elements': sub_elements, + + }) + + # return elements array + return elements + + + + + def get_elements(self) -> list: + + # get site page + if self.start_url is not None: + self.driver.get(self.start_url) + if self.start_url is None: + self.driver.get(self.site.site_url) + start_page = self.driver.current_url + + # record all forms and sub_elements on page + self.elements = self.record_forms(elements=self.elements) + + # grab all buttons + buttons = self.driver.find_elements(By.TAG_NAME, "button") + + # grab all links + links = self.driver.find_elements(By.TAG_NAME, "a") + + # combine buttons and links + start_elms = buttons + links + + # clean start element + cleaned_start_elems = self.get_clean_elements(start_elms) + + # sorting start_elems + sorted_elements = self.get_priority_elements( + elements=cleaned_start_elems, + ) + priority_elements = sorted_elements['priority_elements'] + non_priority_elements = sorted_elements['non_priority_elements'] + + # ending early if not enough elements to generate with + if len(priority_elements) <= 1 and len(non_priority_elements) <= 1: + return self.elements + + # choosing random priority element + if len(priority_elements) > 0: + choosen = priority_elements[ + random.randint(0, (len(priority_elements) - 1)) if len(priority_elements) > 1 else 0 + ] + self.final_start_elements.append( + self.driver.execute_script(self.selector_script, choosen) + ) + + # adding random elements to self.final_start_elements[] + # until max_cases" is reached + iterations = 0 + while (len(self.final_start_elements) + len(self.elements)) < self.max_cases and iterations < (5 * self.max_cases): + + # random choice + choosen = non_priority_elements[ + random.randint(0, (len(non_priority_elements) - 1)) if len(non_priority_elements) > 1 else 0 + ] + + # checking if chosen element is visible + if not self.is_element_visible(choosen): + iterations += 1 + continue + + # check if element exists in self.final_start_elements[] + selector = self.driver.execute_script(self.selector_script, choosen) + if selector in self.final_start_elements: + iterations += 1 + continue + + # ensuring link is local to site + if choosen.tag_name == 'a': + link_text = choosen.get_attribute('href') + + # check it elem_link is blank + if link_text is None: + print('link_text not present') + iterations += 1 + continue + + if link_text is not None: + if link_text.startswith(self.get_url_root(start_page)): + self.final_start_elements.append(selector) + + # adding if button + if choosen.tag_name == 'button': + self.final_start_elements.append(selector) + + # forcing loop to quit if not enough cases are created + iterations += 1 + + + # begin elem iteration + iterations = 0 + for selector in self.final_start_elements: + + # ensuring we're at start_page + if self.driver.current_url != start_page: + self.driver.get(start_page) + driver_wait( + driver=self.driver, + interval=self.configs.get('interval'), + max_wait_time=self.configs.get('max_wait_time'), + min_wait_time=self.configs.get('min_wait_time'), + ) + + # getting element by selector + try: + element = self.driver.find_element(By.CSS_SELECTOR, selector) + except Exception as e: + print('Element not Reachable, removing') + self.final_start_elements.remove(selector) + iterations += 1 + continue + + # get element info + element_img = self.get_element_image(element=element) + element_type = element.tag_name + elem_relative_url = self.get_relative_url(self.driver.current_url) + elem_text = self.get_elem_text(selector=selector) + xpath = self.driver.execute_script(self.xpath_script, element) + + print(f'working on this start element -> {selector}') + + # get all current elements and url before action + old_elements = self.get_current_elements() + previous_url = self.driver.current_url + + # perform first action + try: + element.click() + except Exception as e: + print('Element not Clickable, removing') + self.final_start_elements.remove(selector) + continue + + + # begin layering (max_layers) + layers = 0 + run = True + sub_elements = [] + while layers < self.max_layers and run: + + print(f'on layer -> {layers}') + + # driver wait + driver_wait( + driver=self.driver, + interval=self.configs.get('interval'), + max_wait_time=self.configs.get('max_wait_time'), + min_wait_time=self.configs.get('min_wait_time'), + ) + + # check current page + if self.driver.current_url == previous_url: + + # check for new element + new_elements = self.get_current_elements() + + # cleaning new elements + cleaned_elements = self.get_clean_elements(new_elements, check_against=sub_elements) + + # iterating through each elem + recorded_element = False + for elem in cleaned_elements: + + # record element and increment if necessary + data = self.record_new_element(elem, sub_elements) + run = data['run'] + layers += 1 if data['added'] else 0 + sub_elements = data['sub_elements'] + recorded_element = data['added'] + + # add to layers + if not recorded_element: + layers += 1 + + + # check if page is different but still on site + elif self.driver.current_url != previous_url and \ + self.driver.current_url.startswith(self.get_url_root(previous_url)): + + # get new elements and randomly choose 1 (with priority) + new_elements = self.get_current_elements() + + # cleaning new elements + cleaned_elements = self.get_clean_elements(new_elements, check_against=sub_elements) + + # sort new elements + sorted_elements = self.get_priority_elements( + elements=cleaned_elements, + ) + priority_elements = sorted_elements['priority_elements'] + non_priority_elements = sorted_elements['non_priority_elements'] + elem = None + + # choosing random priority elememt + if len(priority_elements) > 0: + elem = priority_elements[ + random.randint(0, (len(priority_elements) - 1)) if len(priority_elements) > 1 else 0 + ] + print(f'chose priority element | type -> {elem.tag_name}') + + # choosing a random non-priority element + elif len(non_priority_elements) > 0: + elem = non_priority_elements[ + random.randint(0, (len(non_priority_elements) - 1)) if len(non_priority_elements) > 1 else 0 + ] + print(f'chose non-priority element | type -> {elem.tag_name}') + + # returning early if no elem selected + if not elem: + print('no element was selected') + # add to layers and ending case + layers += 1 + run = False + break + + # record element and increment if necessary + data = self.record_new_element(elem, sub_elements) + run = data['run'] + layers += 1 if data['added'] else 0 + sub_elements = data['sub_elements'] + + # catching all other situations + # naving back to previous_url + if not data['added']: + print('no coditions were met') + # add to layers + layers += 1 + # going back + self.driver.get(previous_url) + + # catching all other situations + # naving back to previous_url + else: + print('no coditions were met') + # add to layers + layers += 1 + # going back + self.driver.get(previous_url) + + + # adding final info to elememt list + self.elements.append({ + 'selector': selector, + 'xpath': xpath, + 'elem_type': element_type, + 'elem_text': elem_text, + 'placeholder': None, + 'value': None, + 'type': None, + 'data': None, + 'action': 'click', + 'path': elem_relative_url, + 'img': element_img, + 'elements': sub_elements, + }) + + # counting for process + iterations += 1 + + # update process + self.update_process(current=iterations, total=len(self.final_start_elements)) + + # quit driver session + quit_driver(self.driver) + + # return elements + return self.elements + + + + + def build_cases(self) -> None: + + # run get_elements + try: + elements = self.get_elements() + except Exception as e: + # catching failures + print(e) + elements = [] + + + # get/decide on value for element + def get_elem_value(element): + if element['value'] == None or len(element['value']) <= 0: + return element['data'] + else: + return element['value'] + + # for each high-level element, + # build a new `Case` and save "steps" + # as .json file uploaded to S3 + for element in elements: + + # defining "steps" + steps = [] + + # adding firt step, which is naving + # to the the starting element's 'path' + steps.append({ + "action":{ + "key": "", + "path": element['path'], + "type": "navigate", + "value": "", + "element": "" + }, + "assertion":{ + "type": "", + "value": "", + "element": { + "selector": "", + "xpath": "", + }, + } + }) + + # adding second step if starting + # element is not a form + if element['elem_type'] != 'form': + steps.append({ + "action":{ + "key": "", + "path": element['path'], + "type": element['action'], + "value": get_elem_value(element), + "element": { + "selector": element['selector'], + "xpath": element['xpath'], + }, + "img": element['img'] + }, + "assertion":{ + "type": "", + "value": "", + "element": { + "selector": "", + "xpath": "", + }, + } + }) + + + # sub_element mapping using recursion + def sub_element_mapping(elements, steps): + if element['elements'] != None: + for elem in elements: + # add step + if elem['action'] is not None: + steps.append({ + "action":{ + "key": "", + "path": elem['path'], + "type": elem['action'], + "value": get_elem_value(elem), + "element": { + "selector": elem['selector'], + "xpath": elem['xpath'], + }, + "img": elem['img'] + }, + "assertion":{ + "type": "", + "value": "", + "element": { + "selector": "", + "xpath": "", + }, + } + }) + + # check if sub_elements exists + if elem['elements'] != None: + sub_element_mapping(elem['elements'], steps) + + # return mapped sub_elements in steps + return steps + + + # add sub_elements to steps + steps = sub_element_mapping(element['elements'], steps) + + # create .json file for steps and upload to s3 + case_id = uuid.uuid4() + + # saving as json file temporarily + with open(f'{case_id}.json', 'w') as fp: + json.dump(steps, fp) + + # seting up paths + steps_file = os.path.join(settings.BASE_DIR, f'{case_id}.json') + remote_path = f'static/cases/{case_id}.json' + root_path = settings.AWS_S3_URL_PATH + steps_url = f'{root_path}/{remote_path}' + + # upload to s3 + with open(steps_file, 'rb') as data: + self.s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), + remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "application/json"} + ) + + # remove local copy + os.remove(steps_file) + + # save new Case + Case.objects.create( + id = case_id, + site = self.site, + site_url = self.site.site_url, + user = self.site.user, + account = self.site.account, + title = element['elem_text'] if len(element['elem_text']) > 0 else f'Case {str(case_id)[0:5]}', + type = "generated", + processed = True, + steps = { + 'url': steps_url, + 'num_steps': len(steps) + }, + ) + + # update process + self.update_process(current=1, total=1, complete=True) + + + return None + + + + diff --git a/app/api/utils/automations.py b/app/api/utils/automations.py deleted file mode 100644 index ea7be1b9..00000000 --- a/app/api/utils/automations.py +++ /dev/null @@ -1,242 +0,0 @@ -from ..models import * -from .alerts import * -import re, uuid - - - -def automation(automation_id, object_id): - automation = Automation.objects.get(id=automation_id) - schedule = automation.schedule - expressions = automation.expressions - exp_list = [] - actions = automation.actions - act_list = [] - scan = None - test = None - report = None - testcase = None - use_exp = True - - if schedule.task_type == 'scan': - try: - scan = Scan.objects.get(id=object_id) - except: - return False - - elif schedule.task_type == 'test': - try: - test = Test.objects.get(id=object_id) - except: - return False - - elif schedule.task_type == 'report': - try: - report = Report.objects.get(id=object_id) - use_exp = False - except: - return False - elif schedule.task_type == 'testcase': - try: - testcase = Testcase.objects.get(id=object_id) - use_exp = True - except: - return False - else: - return False - - - - - if use_exp: - - for expression in expressions: - - exp = None - data_type = None - - if testcase == None: - value = str(float(re.search(r'\d+', str(expression['value'])).group())) - - if '>=' in expression['operator']: - operator = ' >= ' - else: - operator = ' <= ' - - if 'and' in expression['joiner']: - joiner = ' and ' - elif 'or' in expression['joiner']: - joiner = ' or ' - else: - joiner = '' - - if testcase != None: - operator = ' == ' - joiner = '' - data_type = 'testcase.passed' - value = str(expression['value']) - - - if 'test_score' in expression['data_type']: - data_type = 'float(test.score)' - - # lighthouse test data - elif 'current_lighthouse_average' in expression['data_type']: - data_type = 'float(test.lighthouse_delta["scores"]["current_average"])' - elif 'seo_delta' in expression['data_type']: - data_type = 'float(test.lighthouse_delta["scores"]["seo_delta"])' - elif 'pwa_delta' in expression['data_type']: - data_type = 'float(test.lighthouse_delta["scores"]["pwa_delta"])' - elif 'crux_delta' in expression['data_type']: - data_type = 'float(test.lighthouse_delta["scores"]["crux_delta"])' - elif 'best_practices_delta' in expression['data_type']: - data_type = 'float(test.lighthouse_delta["scores"]["best_practices_delta"])' - elif 'performance_delta' in expression['data_type']: - data_type = 'float(test.lighthouse_delta["scores"]["performance_delta"])' - elif 'accessibility_delta' in expression['data_type']: - data_type = 'float(test.lighthouse_delta["scores"]["accessibility_delta"])' - # lighthouse scan data - elif 'lighthouse_average' in expression['data_type']: - data_type = 'float(scan.lighthouse["scores"]["average"])' - elif 'seo' in expression['data_type']: - data_type = 'float(scan.lighthouse["scores"]["seo"])' - elif 'pwa' in expression['data_type']: - data_type = 'float(scan.lighthouse["scores"]["pwa"])' - elif 'crux' in expression['data_type']: - data_type = 'float(scan.lighthouse["scores"]["crux"])' - elif 'best_practices' in expression['data_type']: - data_type = 'float(scan.lighthouse["scores"]["best_practices"])' - elif 'performance' in expression['data_type']: - data_type = 'float(scan.lighthouse["scores"]["performance"])' - elif 'accessibility' in expression['data_type']: - data_type = 'float(scan.lighthouse["scores"]["accessibility"])' - - # yellowlab test data - elif 'current_yellowlab_average' in expression['data_type']: - data_type = 'float(test.yellowlab_delta["scores"]["current_average"])' - elif 'pageWeight_delta' in expression['data_type']: - data_type = 'float(test.yellowlab_delta["scores"]["pageWeight_delta"])' - elif 'requests_delta' in expression['data_type']: - data_type = 'float(test.yellowlab_delta["scores"]["requests_delta"])' - elif 'domComplexity_delta' in expression['data_type']: - data_type = 'float(test.yellowlab_delta["scores"]["domComplexity_delta"])' - elif 'javascriptComplexity_delta' in expression['data_type']: - data_type = 'float(test.yellowlab_delta["scores"]["javascriptComplexity_delta"])' - elif 'badJavascript_delta' in expression['data_type']: - data_type = 'float(test.yellowlab_delta["scores"]["badJavascript_delta"])' - elif 'jQuery_delta' in expression['data_type']: - data_type = 'float(test.yellowlab_delta["scores"]["jQuery_delta"])' - elif 'cssComplexity_delta' in expression['data_type']: - data_type = 'float(test.yellowlab_delta["scores"]["cssComplexity_delta"])' - elif 'badCSS_delta' in expression['data_type']: - data_type = 'float(test.yellowlab_delta["scores"]["badCSS_delta"])' - elif 'fonts_delta' in expression['data_type']: - data_type = 'float(test.yellowlab_delta["scores"]["fonts_delta"])' - elif 'serverConfig_delta' in expression['data_type']: - data_type = 'float(test.yellowlab_delta["scores"]["serverConfig_delta"])' - # yellowlab scan data - elif 'yellowlab_average' in expression['data_type']: - data_type = 'float(scan.yellowlab["scores"]["globalScore"])' - elif 'pageWeight' in expression['data_type']: - data_type = 'float(scan.yellowlab["scores"]["pageWeight"])' - elif 'requests' in expression['data_type']: - data_type = 'float(scan.yellowlab["scores"]["requests"])' - elif 'domComplexity' in expression['data_type']: - data_type = 'float(scan.yellowlab["scores"]["domComplexity"])' - elif 'javascriptComplexity' in expression['data_type']: - data_type = 'float(scan.yellowlab["scores"]["javascriptComplexity"])' - elif 'badJavascript' in expression['data_type']: - data_type = 'float(scan.yellowlab["scores"]["badJavascript"])' - elif 'jQuery' in expression['data_type']: - data_type = 'float(scan.yellowlab["scores"]["jQuery"])' - elif 'cssComplexity' in expression['data_type']: - data_type = 'float(scan.yellowlab["scores"]["cssComplexity"])' - elif 'badCSS' in expression['data_type']: - data_type = 'float(scan.yellowlab["scores"]["badCSS"])' - elif 'fonts' in expression['data_type']: - data_type = 'float(scan.yellowlab["scores"]["fonts"])' - elif 'serverConfig' in expression['data_type']: - data_type = 'float(scan.yellowlab["scores"]["serverConfig"])' - - - elif 'logs' in expression['data_type']: - data_type = 'len(scan.logs)' - - elif 'current_health' in expression['data_type']: - data_type = '((float(test.lighthouse_delta["scores"]["current_average"]) + float(test.yellowlab_delta["scores"]["current_average"]))/2)' - - elif 'health' in expression['data_type']: - data_type = '((float(scan.lighthouse["scores"]["average"]) + float(scan.yellowlab["scores"]["globalScore"]))/2)' - - elif 'avg_image_score' in expression['data_type']: - data_type = 'float(test.images_delta["average_score"])' - - elif 'image_scores' in expression['data_type']: - data_type = '[i["score"] for i in test.images_delta["images"]]' - exp = f'{joiner}any(i{operator}{value} for i in {data_type})' - - if exp is None: - exp = f'{joiner}{data_type}{operator}{value}' - - exp_list.append(exp) - - - - for action in actions: - - if 'slack' in action['action_type']: - action_type = f"\n print('sending slack alert')\ - \n automation_slack(automation_id='{str(automation.id)}', \ - object_id='{str(object_id)}')" - - if 'webhook' in action['action_type']: - action_type = f"\n print('sending webhook alert')\ - \n automation_webhook(request_type='{action['request']}', \ - request_url='{action['url']}', request_data='{action['json']}', \ - automation_id='{str(automation.id)}', \ - object_id='{str(object_id)}')" - - if 'email' in action['action_type']: - action_type = f"\n print('sending email alert')\ - \n automation_email(email='{action['email']}',\ - automation_id='{str(automation.id)}', \ - object_id='{str(object_id)}')" - - if report: - action_type = f"\n print('sending report email')\ - \n automation_report_email(email='{action['email']}',\ - automation_id='{str(automation.id)}', \ - object_id='{str(object_id)}')" - - if 'phone' in action['action_type']: - action_type = f"\n print('sending phone alert')\ - \n automation_phone(phone_number='{action['phone']}', \ - automation_id='{str(automation.id)}', \ - object_id='{str(object_id)}')" - - act = f'{action_type}' - act_list.append(act) - - - exp_string = ' '.join(exp_list) - act_string = ''.join(act_list) - - if not use_exp: - exp_string = '1 == 1' - - automation_logic = f'if {exp_string}:{act_string}' - print(automation_logic) - exec(automation_logic) - - return True - - - - - - - - - - - - \ No newline at end of file diff --git a/app/api/utils/caser.py b/app/api/utils/caser.py index 54f2d814..cb213d11 100644 --- a/app/api/utils/caser.py +++ b/app/api/utils/caser.py @@ -1,70 +1,245 @@ -from .driver_p import driver_init -import time, asyncio, uuid, json, boto3, os -from ..models import * -from datetime import datetime from asgiref.sync import sync_to_async -from scanerr import settings +from cryptography.fernet import Fernet +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from .driver import driver_init, driver_wait, quit_driver +from .issuer import Issuer +from .updater import update_flowrun +from ..models import * +from cursion import settings +from datetime import datetime, timezone +import time, uuid, json, boto3, os, requests + class Caser(): + """ + Run a `CaseRun` for a specific `Site` or + gather element info for new `Case`. + + Expects: { + 'caserun' : object, + 'case' : object, + 'process' : object, + 'flowrun_id' : str, + 'node_index' : str, + } + + - Use `Caser.run()` to run Case as CaseRun + - Use `Caser.pre_run()` to run gather element info for a new Case + Returns -> None + """ - def __init__(self, testcase): - self.testcase = testcase - self.site_url = self.testcase.site.site_url - self.steps = self.testcase.steps - self.case_name = self.testcase.case.name - self.configs = self.testcase.configs - @sync_to_async - def update_testcase( - self, index=None, type=None, start_time=None, end_time=None, - passed=None, exception=None, time_completed=None, image=None, + def __init__( + self, + case : object=None, + caserun : object=None, + process : object=None, + flowrun_id : str=None, + node_index : str=None, ): + + # primary objects + self.case = case + self.caserun = caserun + self.process = process + + # secondary objects + self.site_url = self.caserun.site.site_url if self.caserun else self.case.site.site_url + self.steps = self.caserun.steps if self.caserun else requests.get(self.case.steps['url']).json() + self.configs = self.caserun.configs if self.caserun else settings.CONFIGS + self.flowrun_id = flowrun_id + self.node_index = node_index + self.account = self.case.account if self.case else self.caserun.account + self.secrets = Secret.objects.filter(account=self.account) + + # init driver + self.driver = driver_init( + browser=self.configs.get('browser', 'chrome'), + window_size=self.configs.get('window_size'), + device=self.configs.get('device') + ) + + # Selenium Keys reference + self.s_keys = { + '+': Keys.ADD, + 'Alt': Keys.ALT, + 'ArrowDown': Keys.ARROW_DOWN, + 'ArrowLeft': Keys.ARROW_LEFT, + 'ArrowRight': Keys.ARROW_RIGHT, + 'ArrowUp': Keys.ARROW_UP, + 'Backspace': Keys.BACKSPACE, + 'Control': Keys.CONTROL, + '.': Keys.DECIMAL, + 'Delete': Keys.DELETE, + '/': Keys.DIVIDE, + 'Enter': Keys.ENTER, + '=': Keys.EQUALS, + 'Escape': Keys.ESCAPE, + 'Meta': Keys.META, + '*': Keys.MULTIPLY, + '0': Keys.NUMPAD0, + '1': Keys.NUMPAD1, + '2': Keys.NUMPAD2, + '3': Keys.NUMPAD3, + '4': Keys.NUMPAD4, + '5': Keys.NUMPAD5, + '6': Keys.NUMPAD6, + '7': Keys.NUMPAD7, + '8': Keys.NUMPAD8, + '9': Keys.NUMPAD9, + ';': Keys.SEMICOLON, + 'Shift': Keys.SHIFT, + 'Space': Keys.SPACE, + '-': Keys.SUBTRACT, + 'Tab': Keys.TAB + } + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': ( + f'starting up driver for case run using {self.configs.get('browser', 'chrome')}' + ), + 'object_id': str(self.caserun.id) + }) + + + + + def transpose_data(self, string: str=None) -> str: + """ + Using replaces all vairables in string with + account `Secrets`. + + Expects: { + 'string' : str (to be transposed) + } + + Returns -> transposed string + """ + + # decryption helper + def decrypt_secret(value): + f = Fernet(settings.SECRETS_KEY) + decoded = f.decrypt(value) + return decoded.decode('utf-8') + + # create secrets_list + secrets_list = [] + for secret in self.secrets: + secrets_list.append({ + 'key': '{{'+str(secret.name)+'}}', + 'value': decrypt_secret(secret.value) + }) + + # iterate through secrets and replace data + for item in secrets_list: + string = string.replace( + item['key'], + item['value'] + ) + + # return transposed str + return string + + + + + def update_caserun( + self, index: str=None, type: str=None, start_time: str=None, end_time: str=None, + status: str=None, exception: str=None, time_completed: str=None, image: str=None, + ) -> None: + # updates Tescase for a selenium run (async) if start_time != None: - self.testcase.steps[index][type]['time_created'] = str(start_time) + self.caserun.steps[index][type]['time_created'] = str(start_time) if end_time != None: - self.testcase.steps[index][type]['time_completed'] = str(end_time) - if passed != None: - self.testcase.steps[index][type]['passed'] = passed + self.caserun.steps[index][type]['time_completed'] = str(end_time) + if status != None: + self.caserun.steps[index][type]['status'] = status if exception != None: - self.testcase.steps[index][type]['exception'] = str(exception) + self.caserun.steps[index][type]['exception'] = str(exception) if image != None: - self.testcase.steps[index][type]['image'] = str(image) + self.caserun.steps[index][type]['image'] = str(image) if time_completed != None: - self.testcase.time_completed = time_completed - test_status = True - for step in self.testcase.steps: - if step['action']['passed'] == False: - test_status = False - if step['assertion']['passed'] == False: - test_status = False - self.testcase.passed = test_status + self.caserun.time_completed = time_completed + run_status = 'passed' + for step in self.caserun.steps: + if step['action']['status'] == 'failed': + run_status = 'failed' + if step['assertion']['status'] == 'failed': + run_status = 'failed' + self.caserun.status = run_status - self.testcase.save() + self.caserun.save() return - @sync_to_async - def format_element(self, element): + + + + def update_process( + self, + current: int, + total: int, + complete: bool=False, + ) -> None: + """ + Calculates the current progress of the + task based on current step and total + number of steps expected - then updates self.process + with the info. + + Expcets: { + current : int, + total : int, + complete : bool=False, + } + + Returns -> None + """ + + final_progress = 90 + progress = 0 + success = False + if complete: + progress = 100 + success = True + if not complete: + progress = float((current/total) * final_progress) + + print(f'updating process --> {progress}%') + + # update Process obj + self.process.progress = progress + self.process.success = success + self.process.save() + + + + + def format_element(self, element: object) -> str: elememt = json.dumps(element).rstrip('"').lstrip('"') - return element + return str(element) - - async def save_screenshot(self, page): - ''' + + def save_screenshot(self) -> str: + """ Grabs & uploads a screenshot of the `page` passed in the params. Returns -> `image_url` - - ''' + """ # setup boto3 configurations s3 = boto3.client( @@ -78,11 +253,11 @@ async def save_screenshot(self, page): pic_id = uuid.uuid4() # get screenshot - await page.screenshot({'path': f'{pic_id}.png'}) + self.driver.save_screenshot(f'{pic_id}.png') # seting up paths image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') - remote_path = f'static/testcases/{self.testcase.id}/{pic_id}.png' + remote_path = f'static/caseruns/{self.caserun.id}/{pic_id}.png' root_path = settings.AWS_S3_URL_PATH image_url = f'{root_path}/{remote_path}' @@ -95,263 +270,898 @@ async def save_screenshot(self, page): os.remove(image) # returning image url - return image_url + return image_url - - - - async def run(self): - print(f'beginging testcase for {self.site_url} \ - using case {self.case_name}') - - # initate driver - self.driver = await driver_init() - - # init page obj - self.page = await self.driver.newPage() - - # setting up page with configs - sizes = self.configs['window_size'].split(',') - is_mobile = False - if self.configs['device'] == 'mobile': - is_mobile = True - - self.page_options = { - 'waitUntil': 'networkidle0', - 'timeout': int(self.configs['max_wait_time'])*1000 - } - print(f'setting max timeout to -> {int(self.configs["max_wait_time"])}s') + def save_case_steps(self, steps: dict, case_id: str) -> dict: + """ + Helper function that uploads the "steps" data to + s3 bucket - viewport = { - 'width': int(sizes[0]), - 'height': int(sizes[1]), - 'isMobile': is_mobile, + Expects: { + 'steps' : dict, + 'case_id' : str } - - userAgent = ( - "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 \ - (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36" + + Returns -> data: { + 'num_steps' : int, + 'url' : str + } + """ + + # setup boto3 configurations + s3 = boto3.client( + 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), + aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), + region_name=str(settings.AWS_S3_REGION_NAME), + endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) ) + + # saving as json file temporarily + steps_id = uuid.uuid4() + with open(f'{steps_id}.json', 'w') as fp: + json.dump(steps, fp) - emulate_options = { - 'viewport': viewport, - 'userAgent': userAgent + # seting up paths + steps_file = os.path.join(settings.BASE_DIR, f'{steps_id}.json') + remote_path = f'static/cases/{case_id}/{steps_id}.json' + root_path = settings.AWS_S3_URL_PATH + steps_url = f'{root_path}/{remote_path}' + + # upload to s3 + with open(steps_file, 'rb') as data: + s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), + remote_path, ExtraArgs={ + 'ACL': 'public-read', + 'ContentType': 'application/json', + 'CacheControl': 'max-age=0' + } + ) + + # remove local copy + os.remove(steps_file) + + # format data + data = { + 'num_steps': len(steps), + 'url': steps_url + } + + # return response + return data + + + + + def get_element(self, selector: str=None, xpath: str=None) -> object: + """ + Tries to get element by selector first and + then by xpath. If both fail, then return + None for "element" and True for "failed". + + Expects: { + "selector": str, + "xpath": str, + } + + Returns -> data: { + 'element': object | None, + 'failed': bool + } + """ + + # defaults + failed = True + element = None + + # try selector first + if selector: + try: + element = self.driver.find_element(By.CSS_SELECTOR, selector) + failed = False + except: + pass + # try xpath as backup + if xpath: + try: + element = self.driver.find_element(By.XPATH, xpath) + failed = False + except: + pass + + # return data + data = { + 'element': element, + 'failed': failed + } + return data + + + + + def format_exception(self, exception: str) -> str: + """ + Cleans the passed `exception` of any + system refs and unnecessary info + + Expects: { + "exception": str + } + + Returns -> str + """ + + split_e = str(exception).split('Stacktrace:') + new_exception = split_e[0] + + return new_exception + + + + + def get_element_image(self, element: object) -> str: + """ + Grabs a screenshot of the passed "element" + and returns image data as base64 str. + + Expects: { + "element": object (REQUIRED) } - if self.configs['device'] == 'mobile': - await self.page.emulate(emulate_options) - else: - await self.page.setViewport(viewport) + Returns -> str (base64 encoded) + """ + + try: + image = element.screenshot_as_base64 + # sleep for .5 seconds to let image process + time.sleep(.5) + except: + image = None + return image + + + + + def run(self) -> None: + """ + Runs the self.caserun using selenium as the driver + + Returns -> None + """ + msg = f'starting case run for {self.site_url} using case "{self.caserun.title}" | run_id: {str(self.caserun.id)}' + print(msg) + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': msg, + 'objects': [{ + 'parent': str(self.caserun.site.id), + 'id': str(self.caserun.id), + 'status': 'working' + }] + }) + + # initate driver + self.driver = driver_init( + browser=self.configs.get('browser', 'chrome'), + window_size=self.configs['window_size'], + device=self.configs['device'] + ) + + # setting implict wait_time for driver + self.driver.implicitly_wait(self.configs['max_wait_time']) i = 0 for step in self.steps: - print(f'-- running step #{i+1} --') - # print(f'step contents: {step}') + msg = f'running step #{i+1} | run_id: {str(self.caserun.id)}' + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': msg + }) + + # adding catch if nav is not first + if i == 0 and step['action']['type'] != 'navigate': + print(f'navigating to {self.site_url} before first step') + # using selenium, navigate to site root path & wait for page to load + self.driver.get(f'{self.site_url}') + time.sleep(int(self.configs['min_wait_time'])) + if step['action']['type'] == 'navigate': exception = None - passed = True - image = None - await self.update_testcase( + status = 'passed' + self.update_caserun( index=i, type='action', - start_time=datetime.now() + start_time=datetime.now(timezone.utc) ) try: - print(f'navigating to {self.site_url}{step["action"]["path"]}') - # using puppeteer, navigate to requested path & wait for page to load - await self.page.goto(f'{self.site_url}{step["action"]["path"]}', self.page_options) - time.sleep(int(self.configs['min_wait_time'])) + msg = f'navigating to {self.site_url}{step["action"]["path"]} | run_id: {str(self.caserun.id)}' + print(msg) + + # updating flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': msg + }) + + # using selenium, navigate to requested path & wait for page to load + driver_wait( + driver=self.driver, + interval=int(self.configs.get('interval', 1)), + min_wait_time=int(self.configs.get('min_wait_time', 3)), + max_wait_time=int(self.configs.get('max_wait_time', 30)), + ) + self.driver.get(f'{self.site_url}{step["action"]["path"]}') + time.sleep(int(self.configs.get('min_wait_time', 3))) + image = self.save_screenshot() except Exception as e: - image = await self.save_screenshot(page=self.page) - exception = e - passed = False + image = self.save_screenshot() + exception = self.format_exception(e) + msg = excaption + status = 'failed' + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': f'❌ {exception} | run_id: {str(self.caserun.id)}' + }) + + # update caserun + self.update_caserun( + index=i, type='action', + end_time=datetime.now(timezone.utc), + status=status, + exception=exception, + image=image + ) + + # exit early if configs.end_on_fail == True + if self.caserun.configs.get('end_on_fail', True) and status == 'failed': + break + + + if step['action']['type'] == 'scroll': + exception = None + status = 'passed' + self.update_caserun( + index=i, type='action', + start_time=datetime.now(timezone.utc) + ) + try: + msg = f'scrolling ({step["action"]["value"]}) | run_id: {str(self.caserun.id)}' + print(msg) + + # updating flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message':msg + }) + + # scrolling using plain JavaScript + self.driver.execute_script(f'window.scrollTo({step["action"]["value"]});') + time.sleep(int(self.configs.get('min_wait_time', 3))) + + # get image + image = self.save_screenshot() - await self.update_testcase( + except Exception as e: + image = self.save_screenshot() + exception = self.format_exception(e) + status = 'failed' + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': f'❌ {exception} | run_id: {str(self.caserun.id)}' + }) + + # update caserun + self.update_caserun( index=i, type='action', - end_time=datetime.now(), - passed=passed, + end_time=datetime.now(timezone.utc), + status=status, exception=exception, image=image ) - + # exit early if configs.end_on_fail == True + if self.caserun.configs.get('end_on_fail', True) and status == 'failed': + break + if step['action']['type'] == 'click': exception = None - passed = True - image = None - await self.update_testcase( + status = 'passed' + self.update_caserun( index=i, type='action', - start_time=datetime.now() + start_time=datetime.now(timezone.utc) ) try: - print(f'clicking element -> {step["action"]["element"]}') - # using puppeteer, find and click on the 'element' - selector = await self.format_element(step["action"]["element"]) - await self.page.waitForSelector(selector, timeout=(int(self.configs['max_wait_time'])*1000)) + msg = f'clicking element "{step["action"]["element"]["selector"]}" | run_id: {str(self.caserun.id)}' + print(msg) + + # updating flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message':msg + }) + + # using selenium, find and click on the 'element' + selector = self.format_element(step["action"]["element"]["selector"]) + xpath = self.format_element(step["action"]["element"]["xpath"]) + element_data = self.get_element(selector, xpath) + element = element_data['element'] + + # checking if element was found + if element_data['failed']: + raise Exception(f'Unable to locate element with the given Selector and xPath') + # scrolling to element using plain JavaScript - await self.page.evaluate(f'document.querySelector({selector}).scrollIntoView()') - element = await self.page.J(selector) - await element.click() - time.sleep(int(self.configs['min_wait_time'])) + self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') + self.driver.execute_script("arguments[0].scrollIntoView();", element) + self.driver.execute_script("window.scrollBy(0, -100);") + time.sleep(int(self.configs.get('min_wait_time', 3))) + + # clicking element + element.click() + time.sleep(int(self.configs.get('min_wait_time', 3))) + image = self.save_screenshot() except Exception as e: - image = await self.save_screenshot(page=self.page) - exception = e - passed = False + image = self.save_screenshot() + exception = self.format_exception(e) + status = 'failed' + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': f'❌ {exception} | run_id: {str(self.caserun.id)}' + }) - await self.update_testcase( + # update caserun + self.update_caserun( index=i, type='action', - end_time=datetime.now(), - passed=passed, + end_time=datetime.now(timezone.utc), + status=status, exception=exception, image=image - ) + ) + # exit early if configs.end_on_fail == True + if self.caserun.configs.get('end_on_fail', True) and status == 'failed': + break + if step['action']['type'] == 'change': exception = None - passed = True - image = None - await self.update_testcase( + status = 'passed' + self.update_caserun( index=i, type='action', - start_time=datetime.now() + start_time=datetime.now(timezone.utc) ) try: - print(f'changing element to value -> {step["action"]["value"]}') - # using puppeteer, find and click on the 'element' - if step["action"]["element"] != (None or ''): - selector = await self.format_element(step["action"]["element"]) - await self.page.waitForSelector(selector, timeout=(int(self.configs['max_wait_time'])*1000)) - # scrolling to element using plain JavaScript - await self.page.evaluate(f'document.querySelector({selector}).scrollIntoView()') - element = await self.page.J(selector) - await element.click(clickCount=3) - await self.page.keyboard.type(step["action"]["value"]) - time.sleep(int(self.configs['min_wait_time'])) + msg = f'changing element "{step["action"]["element"]["selector"]}" value to "{step["action"]["value"]}" | run_id: {str(self.caserun.id)}' + print(msg) + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': msg + }) + + # using selenium, find and change the 'element'.value + selector = self.format_element(step["action"]["element"]["selector"]) + xpath = self.format_element(step["action"]["element"]["xpath"]) + element_data = self.get_element(selector, xpath) + element = element_data['element'] + + # checking if element was found + if element_data['failed']: + raise Exception(f'Unable to locate element with the given Selector and xPath') + + # scrolling to element and back down a bit + self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') + self.driver.execute_script("arguments[0].scrollIntoView();", element) + self.driver.execute_script("window.scrollBy(0, -100);") + time.sleep(int(self.configs.get('min_wait_time', 3))) + + # changing value of element + value = self.transpose_data(step["action"]["value"]) + element.send_keys(value) + time.sleep(int(self.configs.get('min_wait_time', 3))) + image = self.save_screenshot() except Exception as e: - image = await self.save_screenshot(page=self.page) - exception = e - passed = False + image = self.save_screenshot() + exception = self.format_exception(e) + status = 'failed' + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': f'❌ {exception} | run_id: {str(self.caserun.id)}' + }) - await self.update_testcase( + # update caserun + self.update_caserun( index=i, type='action', - end_time=datetime.now(), - passed=passed, + end_time=datetime.now(timezone.utc), + status=status, exception=exception, image=image - ) + ) + + # exit early if configs.end_on_fail == True + if self.caserun.configs.get('end_on_fail', True) and status == 'failed': + break + - if step['action']['type'] == 'keyDown': exception = None - passed = True - image = None - await self.update_testcase( + status = 'passed' + self.update_caserun( index=i, type='action', - start_time=datetime.now() + start_time=datetime.now(timezone.utc) ) try: - print(f'keyDown action for key -> {step["action"]["key"]}') - # using puppeteer, press the selected key - await self.page.keyboard.press(step['action']['key']) - time.sleep(int(self.configs['min_wait_time'])) + msg = f'keyDown action using key "{step["action"]["key"]}" | run_id: {str(self.caserun.id)}' + print(msg) + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': msg + }) + + # getting last known element + n = (i - 1) + elm = None + while True: + elm = self.steps[n]['action']['element']['selector'] + if elm != None and len(elm) != 0: + break + n -= 1 + selector = self.format_element(elm) + + # using selenium, find elemenmtn and send 'Key' event + selector = self.format_element(step["action"]["element"]["selector"]) + xpath = self.format_element(step["action"]["element"]["xpath"]) + element_data = self.get_element(selector, xpath) + element = element_data['element'] + + # checking if element was found + if element_data['failed']: + raise Exception(f'Unable to locate element with the given Selector and xPath') + + # scrolling to element and back down a bit + self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') + self.driver.execute_script("arguments[0].scrollIntoView();", element) + self.driver.execute_script("window.scrollBy(0, -100);") + time.sleep(int(self.configs.get('min_wait_time', 3))) + + # using selenium, press the selected key + element.send_keys(self.s_keys.get(step["action"]["key"], step["action"]["key"])) + time.sleep(int(self.configs.get('min_wait_time', 3))) + image = self.save_screenshot() except Exception as e: - image = await self.save_screenshot(page=self.page) - exception = e - passed = False + image = self.save_screenshot() + exception = self.format_exception(e) + status = 'failed' + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': f'❌ {exception} | run_id: {str(self.caserun.id)}' + }) - await self.update_testcase( + # update caserun + self.update_caserun( index=i, type='action', - end_time=datetime.now(), - passed=passed, + end_time=datetime.now(timezone.utc), + status=status, exception=exception, image=image ) - + # exit early if configs.end_on_fail == True + if self.caserun.configs.get('end_on_fail', True) and status == 'failed': + break if step['assertion']['type'] == 'match': exception = None - passed = True - image = None - await self.update_testcase( + status = 'passed' + self.update_caserun( index=i, type='assertion', - start_time=datetime.now() + start_time=datetime.now(timezone.utc) ) try: - print(f'asserting that element value -> {step["assertion"]["element"]} matches {step["assertion"]["value"]}') - # using puppeteer, find elememt and assert if element.text == assertion.text - selector = await self.format_element(step["assertion"]["element"]) - await self.page.waitForSelector(selector, timeout=(int(self.configs['max_wait_time'])*1000)) - # scrolling to element using plain JavaScript - await self.page.evaluate(f'document.querySelector({selector}).scrollIntoView()') - elementText = await self.page.evaluate(f'document.querySelector({selector}).textContent') + # using selenium, find elememt and assert if element.text == assertion.value + msg = f'asserting that element "{step["assertion"]["element"]["selector"]}".innerText matches "{step["assertion"]["value"]}" | run_id: {str(self.caserun.id)}' + print(msg) + + # updating flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': msg + }) + + selector = self.format_element(step["assertion"]["element"]["selector"]) + xpath = self.format_element(step["assertion"]["element"]["xpath"]) + element_data = self.get_element(selector, xpath) + element = element_data['element'] + + # checking if element was found + if element_data['failed']: + raise Exception(f'Unable to locate element with the given Selector and xPath') + + # scrolling to element and back down a bit + self.driver.execute_script("arguments[0].scrollIntoView();", element) + self.driver.execute_script("window.scrollBy(0, -100);") + time.sleep(int(self.configs.get('min_wait_time', 3))) + + # gettintg elem text + elementText = element.get_attribute('innerText') + elementText = element.text if len(elementText) == 0 else elementText elementText = elementText.strip() - print(f'elementText => {elementText}') - print(f'value => {step["assertion"]["value"]}') - assert elementText == step["assertion"]["value"] + print(f'elementText -> {elementText}') + print(f'value -> {step["assertion"]["value"]}') + + # assert text + if elementText != self.transpose_data(step["assertion"]["value"]): + raise AssertionError(f'innerText of element "{selector}" does match expected') + + # save screenshot + image = self.save_screenshot() except Exception as e: - image = await self.save_screenshot(page=self.page) - exception = e - passed = False + image = self.save_screenshot() + exception = self.format_exception(e) + status = 'failed' + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': f'❌ {exception} | run_id: {str(self.caserun.id)}' + }) - await self.update_testcase( + # update caserun + self.update_caserun( index=i, type='assertion', - end_time=datetime.now(), - passed=passed, + end_time=datetime.now(timezone.utc), + status=status, exception=exception, image=image ) + # exit early if configs.end_on_fail == True + if self.caserun.configs.get('end_on_fail', True) and status == 'failed': + break + if step['assertion']['type'] == 'exists': exception = None - passed = True - image = None - await self.update_testcase( + status = 'passed' + self.update_caserun( index=i, type='assertion', - start_time=datetime.now() + start_time=datetime.now(timezone.utc) ) try: - print(f'asserting that element -> {step["assertion"]["element"]} exists') - # using puppeteer, find elememt and assert it exists - selector = await self.format_element(step["assertion"]["element"]) - await self.page.waitForSelector(selector, timeout=(int(self.configs['max_wait_time'])*1000)) - await self.page.J(selector) + msg = f'asserting that {step["assertion"]["element"]["selector"]} exists | run_id: {str(self.caserun.id)}' + print(msg) + + # updating flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': msg + }) + + # find elememt and assert it exists + selector = self.format_element(step["action"]["element"]["selector"]) + xpath = self.format_element(step["action"]["element"]["xpath"]) + element_data = self.get_element(selector, xpath) + element = element_data['element'] + + # checking if element was found + if element_data['failed']: + raise Exception(f'Unable to locate element with the given Selector and xPath') + + # scrolling to element and back down a bit + self.driver.execute_script("arguments[0].scrollIntoView();", element) + self.driver.execute_script("window.scrollBy(0, -100);") + + # scrolling to element using plain JavaScript + self.driver.execute_script("arguments[0].scrollIntoView();", element) + image = self.save_screenshot() except Exception as e: - image = await self.save_screenshot(page=self.page) - exception = e - passed = False + image = self.save_screenshot() + exception = self.format_exception(e) + status = 'failed' + + # updating flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': f'❌ {exception} | run_id: {str(self.caserun.id)}' + }) - await self.update_testcase( + self.update_caserun( index=i, type='assertion', - end_time=datetime.now(), - passed=passed, + end_time=datetime.now(timezone.utc), + status=status, exception=exception, image=image ) - i += 1 - await self.update_testcase( - time_completed=datetime.now() + # exit early if configs.end_on_fail == True + if self.caserun.configs.get('end_on_fail', True) and status == 'failed': + break + + i += 1 + + self.update_caserun( + time_completed=datetime.now(timezone.utc) ) - await self.driver.close() - print('-- testcase run complete --') \ No newline at end of file + quit_driver(driver=self.driver) + print('-- caserun run complete --') + + # update flowrun + if self.flowrun_id: + update_flowrun(**{ + 'flowrun_id': self.flowrun_id, + 'node_index': self.node_index, + 'message': ( + f'case run "{self.caserun.title}" for {self.caserun.site.site_url} completed with status: '+ + f'{"❌ FAILED" if self.caserun.status == 'failed' else "✅ PASSED"} | run_id: {str(self.caserun.id)}' + ), + 'objects': [{ + 'parent': str(self.caserun.site.id), + 'id': str(self.caserun.id), + 'status': self.caserun.status + }], + 'node_status': self.caserun.status + }) + + if self.caserun.status == 'failed' and self.caserun.configs.get('create_issue'): + print('generating new Issue...') + Issuer(caserun=self.caserun).build_issue() + + return None + + + + + def pre_run(self) -> None: + """ + Runs the self.case using selenium as the driver + and tries to collect element img data. + + Returns -> None + """ + + print(f'beginning pre_run for Case {self.case.title}') + + # setting implict wait_time for driver + self.driver.implicitly_wait(self.configs.get('max_wait_time')) + + i = 0 + for step in self.steps: + print(f'-- running step #{i+1} --') + + # adding catch if nav is not first + if i == 0 and step['action']['type'] != 'navigate': + print(f'navigating to {self.site_url} before first step') + # using selenium, navigate to site root path & wait for page to load + self.driver.get(f'{self.site_url}') + time.sleep(int(self.configs['min_wait_time'])) + + + if step['action']['type'] == 'navigate': + try: + print(f'navigating to {self.site_url}{step["action"]["path"]}') + # using selenium, navigate to requested path & wait for page to load + driver_wait( + driver=self.driver, + interval=int(self.configs.get('interval', 1)), + min_wait_time=int(self.configs.get('min_wait_time', 3)), + max_wait_time=int(self.configs.get('max_wait_time', 30)), + ) + self.driver.get(f'{self.site_url}{step["action"]["path"]}') + time.sleep(int(self.configs.get('min_wait_time', 3))) + + except Exception as e: + print(e) + + + if step['action']['type'] == 'scroll': + try: + print(f'scrolling -> {step["action"]["value"]}') + # scrolling using plain JavaScript + self.driver.execute_script(f'window.scrollTo({step["action"]["value"]});') + time.sleep(int(self.configs.get('min_wait_time', 3))) + + except Exception as e: + print(e) + + + if step['action']['type'] == 'click': + try: + print(f'clicking element -> {step["action"]["element"]}') + # using selenium, find and click on the 'element' + selector = self.format_element(step["action"]["element"]["selector"]) + xpath = self.format_element(step["action"]["element"]["xpath"]) + element_data = self.get_element(selector, xpath) + element = element_data['element'] + + # checking if element was found + if element_data['failed']: + raise Exception(f'Unable to locate element with the given Selector and xPath') + + # scrolling to element using plain JavaScript + self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') + self.driver.execute_script("arguments[0].scrollIntoView();", element) + self.driver.execute_script("window.scrollBy(0, -100);") + time.sleep(int(self.configs.get('min_wait_time', 3))) + + # get elem img & update self.steps + img = self.get_element_image(element) + self.steps[i]['action']['img'] = img + + # clicking element + element.click() + time.sleep(int(self.configs.get('min_wait_time', 3))) + + except Exception as e: + print(e) + + + if step['action']['type'] == 'change': + try: + print(f'changing element to value -> {step["action"]["value"]}') + # using selenium, find and change the 'element'.value + selector = self.format_element(step["action"]["element"]["selector"]) + xpath = self.format_element(step["action"]["element"]["xpath"]) + element_data = self.get_element(selector, xpath) + element = element_data['element'] + + # checking if element was found + if element_data['failed']: + raise Exception(f'Unable to locate element with the given Selector and xPath') + + # scrolling to element and back down a bit + self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') + self.driver.execute_script("arguments[0].scrollIntoView();", element) + self.driver.execute_script("window.scrollBy(0, -100);") + time.sleep(int(self.configs.get('min_wait_time', 3))) + + # get elem img & update self.steps + img = self.get_element_image(element) + self.steps[i]['action']['img'] = img + + # changing value of element + value = step["action"]["value"] + element.send_keys(value) + time.sleep(int(self.configs.get('min_wait_time', 3))) + + except Exception as e: + print(e) + + + if step['action']['type'] == 'keyDown': + try: + print(f'keyDown action for key -> {step["action"]["key"]}') + # getting last known element + n = (i - 1) + elm = None + while True: + elm = self.steps[n]['action']['element']['selector'] + if elm != None and len(elm) != 0: + break + n -= 1 + selector = self.format_element(elm) + + # using selenium, find element and send 'Key' event + selector = self.format_element(step["action"]["element"]["selector"]) + xpath = self.format_element(step["action"]["element"]["xpath"]) + element_data = self.get_element(selector, xpath) + element = element_data['element'] + + # checking if element was found + if element_data['failed']: + raise Exception(f'Unable to locate element with the given Selector and xPath') + + # scrolling to element and back down a bit + self.driver.execute_script(f'document.querySelector("{selector}").scrollIntoView()') + self.driver.execute_script("arguments[0].scrollIntoView();", element) + self.driver.execute_script("window.scrollBy(0, -100);") + time.sleep(int(self.configs.get('min_wait_time', 3))) + + # get elem img & update self.steps + img = self.get_element_image(element) + self.steps[i]['action']['img'] = img + + # using selenium, press the selected key + element.send_keys(self.s_keys.get(step["action"]["key"], step["action"]["key"])) + time.sleep(int(self.configs.get('min_wait_time', 3))) + + except Exception as e: + print(e) + + + # increment step + i += 1 + + # update process + self.update_process( + current=(i+1), + total=self.case.steps['num_steps'], + complete=False + ) + + + # update case + steps_data = self.save_case_steps(self.steps, str(self.case.id)) + self.case.steps = steps_data + self.case.processed = True + self.case.save() + + quit_driver(driver=self.driver) + print('-- case pre_run complete --') + + # update process + self.update_process(current=1, total=1, complete=True) + + return None + + + + + + \ No newline at end of file diff --git a/app/api/utils/crawler.py b/app/api/utils/crawler.py new file mode 100644 index 00000000..4555ef12 --- /dev/null +++ b/app/api/utils/crawler.py @@ -0,0 +1,184 @@ +from bs4 import BeautifulSoup +from .driver import * + + + + + + +class Crawler(): + """ + Crawl the passed "site" for pages, stoping + once 'max_urls' is reached. + + Expects: { + 'url' : str, + 'sitemap' : str, + 'start_url' : str, + 'max_urls' : int, + } + + Use `Crawler.get_links()` initiate a new crawl + + Returns -> list + """ + + + + + def __init__(self, url: str=None, sitemap: str=None, max_urls: int=5): + self.url = url + self.sitemap = sitemap + self.max_urls = max_urls + self.driver = driver_init() + + + + + def get_links(self) -> list: + # crawl self.url and record any found links + # which are within the same self.url domain + + follow_urls = [] + crawled_urls = [] + saved_urls = [self.url,] + + + def url_is_valid(url: str=None) -> bool: + # checks if the passed url is + # a valid url to follow and + # not a file or external redirect + + bad_str_list = ['cdn-cgi'] + bad_end_list = [ + '.png', '.jpg', '.pdf', '.jpeg', + '.json', '.doc', '.svg', '.ppt', + '.pptx', '.ods', '.docx', '.mp3', + '.mp4', '.wma', '.ogg', '.mpa', + '.wpl', '.zip', '.pkg', '.tar.gz', + '.deb', '.z', '.rpm', '.7z', '.bin', + '.dmg', '.iso', '.toast', '.vcd', + '.csv', 'xml', '.db', '.dbf', '.dat', + '.log', '.mdb', '.sql', '.tar', '.sav', + '.webp', '.tiff', '.tif', '.psd', '.ps', + '.ico', '.gif', '.bmp' + ] + if not url.startswith(self.url) and not url.startswith('/'): + return False + for bad_str in bad_str_list: + if bad_str in url: + return False + for bad_end in bad_end_list: + if url.endswith(bad_end): + return False + return True + + + def crawl_url(start_url: str=None, max_depth: int=5): + + print(f'starting crawl on -> {start_url}') + + # adding url to list of crawled_urls + crawled_urls.append(start_url) + + # setting depth + depth = 0 + + # get requested start_url + self.driver.get(start_url) + + # wait for page to load + driver_wait( + driver=self.driver, + max_wait_time=20, + interval=2 + ) + + # parsing page_source + soup = BeautifulSoup(self.driver.page_source, 'html.parser') + + # iterating through all tags + + for link in soup.find_all('a'): + + # check if max_depth has been reached + if depth >= max_depth: + break + + url = link.get('href') + if url is not None: + # validate url + if url_is_valid(url): + if url.startswith('/'): + url = self.url + url + + # check status of page + self.driver.get(url) + + print(f'looped to this url -> {url}') + + # wait for page to load + resolved = driver_wait( + driver=self.driver, + max_wait_time=20, + interval=2 + ) + + # skipping url if not responding + if not resolved: + print('not resolved') + continue + + # clean and decide to record url + if str(self.driver.current_url) == str(url): + if url.endswith('/'): + url = url.rstrip('/') + if not (url in follow_urls): + follow_urls.append(url) + depth += 1 + print(f'{depth} urls saved of {max_depth} allowed') + + + def record_urls(): + # adds all follow_urls to saved_urls + # if not already recorded + + max_reached = False + + # iterate through existing follow_urls + for url in follow_urls: + # pass if already crawled + if not url in crawled_urls: + if not url in saved_urls: + saved_urls.append(url) + print(f'saving -> {url}') + if len(saved_urls) >= self.max_urls: + print('max pages reached') + max_reached = True + break + return max_reached + + # layer 0 + crawl_url(self.url, max_depth=self.max_urls) + + # iterate through layers + while (len(follow_urls) > len(saved_urls)) and (len(saved_urls) < self.max_urls): + + # crawl each follow_url that + # has not been crawled + for url in follow_urls: + # add existing follow_urls first + max_reached = record_urls() + if max_reached: + break + + # crawl new url if not in crawled_urls + if not url in crawled_urls: + crawl_url(url, max_depth=self.max_urls) + + + # quit driver and return + quit_driver(self.driver) + return saved_urls + + diff --git a/app/api/utils/crux.py b/app/api/utils/crux.py deleted file mode 100644 index 73601c4e..00000000 --- a/app/api/utils/crux.py +++ /dev/null @@ -1,36 +0,0 @@ -import requests, os, json - - - -class Crux(): - - def __init__(self, site_url): - self.site_url = site_url - self.key = os.environ.get('GOOGLE_CRUX_KEY') - - - def get_data(self): - - url = f'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key={self.key}' - headers = { - "Content-Type": "application/json", - } - data = { - "origin": str(self.site_url), - } - - res = requests.post( - url=url, - headers=headers, - data=json.dumps(data) - ) - - response = res.json() - - if res.status_code != 200: - response = { - "status": "failed", - "message": "This site_url does not have enough historical data in the CRUX API to respond with." - } - - return response diff --git a/app/api/utils/custom-config.js b/app/api/utils/custom-config.js index cae0befc..a4fadfce 100644 --- a/app/api/utils/custom-config.js +++ b/app/api/utils/custom-config.js @@ -1,14 +1,11 @@ // custom configurations for Lighthouse CLI - - - module.exports = { extends: 'lighthouse:default', plugins: ['lighthouse-plugin-crux'], settings: { cruxToken: process.env.GOOGLE_CRUX_KEY, skipAudits: [ - "full-page-screenshot", + // "full-page-screenshot", ], }, } \ No newline at end of file diff --git a/app/api/utils/definitions.py b/app/api/utils/definitions.py new file mode 100644 index 00000000..465fe248 --- /dev/null +++ b/app/api/utils/definitions.py @@ -0,0 +1,359 @@ +# Data definitions used throughout +# The Cursion platform + + + + +definitions = [ + + # high-level test score + { + 'name': 'Test Score', + 'key': 'test_score', + 'value': 'obj.score' + }, + { + 'name': 'Health', + 'key': 'current_health', + 'value': 'float(obj.post_scan.score)' + }, + { + 'name': 'Avg Image Score', + 'key': 'avg_image_score', + 'value': 'obj.images_delta.get("average_score",0)' + }, + { + 'name': 'List of Image Scores', + 'key': 'image_scores', + 'value': 'str([i["score"] for i in obj.images_delta["images"]])' + }, + { + 'name': 'Test Status', + 'key': 'test_status', + 'value': 'obj.status' + }, + + # high-level scan score + { + 'name': 'Health', + 'key': 'health', + 'value': 'float(obj.score)' + }, + { + 'name': 'Error Logs', + 'key': 'logs', + 'value': 'len(obj.logs)' + }, + + # LH test data + { + 'name': 'SEO Delta', + 'key': 'seo_delta', + 'value': 'obj.lighthouse_delta["scores"].get("seo_delta",0)' + }, + { + 'name': 'PWA Delta', + 'key': 'pwa_delta', + 'value': 'obj.lighthouse_delta["scores"].get("pwa_delta",0)' + }, + { + 'name': 'CRUX Delta', + 'key': 'crux_delta', + 'value': 'obj.lighthouse_delta["scores"].get("crux_delta",0)' + }, + { + 'name': 'Best Practices Delta', + 'key': 'best_practices_delta', + 'value': 'obj.lighthouse_delta["scores"].get("best_practices_delta",0)' + }, + { + 'name': 'Performance Delta', + 'key': 'performance_delta', + 'value': 'obj.lighthouse_delta["scores"].get("performance_delta",0)' + }, + { + 'name': 'Accessibility Delta', + 'key': 'accessibility_delta', + 'value': 'obj.lighthouse_delta["scores"].get("accessibility_delta",0)' + }, + { + 'name': 'Lighthouse Average', + 'key': 'current_lighthouse_average', + 'value': 'obj.lighthouse_delta["scores"].get("current_average",0)' + }, + + # LH scan data + { + 'name': 'Lighthouse Average', + 'key': 'lighthouse_average', + 'value': 'obj.lighthouse["scores"].get("average",0)' + }, + { + 'name': 'SEO', + 'key': 'seo', + 'value': 'obj.lighthouse["scores"].get("seo",0)' + }, + { + 'name': 'PWA', + 'key': 'pwa', + 'value': 'obj.lighthouse["scores"].get("pwa",0)' + }, + { + 'name': 'CRUX', + 'key': 'crux', + 'value': 'obj.lighthouse["scores"].get("crux",0)' + }, + { + 'name': 'Best Practice', + 'key': 'best_practices', + 'value': 'obj.lighthouse["scores"].get("best_practices",0)' + }, + { + 'name': 'Performance', + 'key': 'performance', + 'value': 'obj.lighthouse["scores"].get("performance",0)' + }, + { + 'name': 'Accessibility', + 'key': 'accessibility', + 'value': 'obj.lighthouse["scores"].get("accessibility",0)' + }, + + # YL test data + { + 'name': 'Yellowlab Average', + 'key': 'current_yellowlab_average', + 'value': 'obj.yellowlab_delta["scores"].get("current_average",0)' + }, + { + 'name': 'Page Weight Delta', + 'key': 'pageWeight_delta', + 'value': 'obj.yellowlab_delta["scores"].get("pageWeight_delta",0)' + }, + { + 'name': 'Images Delta', + 'key': 'images_delta', + 'value': 'obj.yellowlab_delta["scores"].get("images_delta",0)' + }, + { + 'name': ' DOM Complexity Delta', + 'key': 'domComplexity_delta', + 'value': 'obj.yellowlab_delta["scores"].get("domComplexity_delta",0)' + }, + { + 'name': 'JS Complexity Delta', + 'key': 'javascriptComplexity_delta', + 'value': 'obj.yellowlab_delta["scores"].get("javascriptComplexity_delta",0)' + }, + { + 'name': 'Bad JS Delta', + 'key': 'badJavascript_delta', + 'value': 'obj.yellowlab_delta["scores"].get("badJavascript_delta",0)' + }, + { + 'name': 'jQuery Delta', + 'key': 'jQuery_delta', + 'value': 'obj.yellowlab_delta["scores"].get("jQuery_delta",0)' + }, + { + 'name': 'CSS Complexity Delta', + 'key': 'cssComplexity_delta', + 'value': 'obj.yellowlab_delta["scores"].get("cssComplexity_delta",0)' + }, + { + 'name': 'Bad CSS Delta', + 'key': 'badCSS_delta', + 'value': 'obj.yellowlab_delta["scores"].get("badCSS_delta",0)' + }, + { + 'name': 'Fonts Delta', + 'key': 'fonts_delta', + 'value': 'obj.yellowlab_delta["scores"].get("fonts_delta",0)' + }, + { + 'name': 'Server Config Delta', + 'key': 'serverConfig_delta', + 'value': 'obj.yellowlab_delta["scores"].get("serverConfig_delta",0)' + }, + + # YL scan data + { + 'name': 'Yellowlab Average', + 'key': 'yellowlab_average', + 'value': 'obj.yellowlab["scores"].get("globalScore",0)' + }, + { + 'name': 'Page Weight', + 'key': 'pageWeight', + 'value': 'obj.yellowlab["scores"].get("pageWeight",0)' + }, + { + 'name': 'Images', + 'key': 'images', + 'value': 'obj.yellowlab["scores"].get("images",0)' + }, + { + 'name': 'DOM Complexity', + 'key': 'domComplexity', + 'value': 'obj.yellowlab["scores"].get("domComplexity",0)' + }, + { + 'name': 'JS Complexity', + 'key': 'javascriptComplexity', + 'value': 'obj.yellowlab["scores"].get("javascriptComplexity",0)' + }, + { + 'name': 'Bad JS', + 'key': 'badJavascript', + 'value': 'obj.yellowlab["scores"].get("badJavascript",0)' + }, + { + 'name': 'jQuery', + 'key': 'jQuery', + 'value': 'obj.yellowlab["scores"].get("jQuery",0)' + }, + { + 'name': 'CSS Complexity', + 'key': 'cssComplexity', + 'value': 'obj.yellowlab["scores"].get("cssComplexity",0)' + }, + { + 'name': 'Bad CSS', + 'key': 'badCSS', + 'value': 'obj.yellowlab["scores"].get("badCSS",0)' + }, + { + 'name': 'Fonts', + 'key': 'fonts', + 'value': 'obj.yellowlab["scores"].get("fonts",0)' + }, + { + 'name': 'Server Configs', + 'key': 'serverConfig', + 'value': 'obj.yellowlab["scores"].get("serverConfig",0)' + }, + + # caserun + { + 'name': 'Case Run Status', + 'key': 'caserun_status', + 'value': 'obj.status' + }, + { + 'name': 'Case Run ID', + 'key': 'caserun_id', + 'value': 'str(obj.id)' + }, + { + 'name': 'Case Title', + 'key': 'case_title', + 'value': 'obj.title' + }, + { + 'name': 'Case ID', + 'key': 'case_id', + 'value': 'str(obj.case.id)' + }, + + # flowrun + { + 'name': 'Flow Run Status', + 'key': 'flowrun_status', + 'value': 'obj.status' + }, + { + 'name': 'Flow Run ID', + 'key': 'flowrun_id', + 'value': 'str(obj.id)' + }, + { + 'name': 'Flow Title', + 'key': 'flow_title', + 'value': 'obj.title' + }, + { + 'name': 'Flow ID', + 'key': 'flow_id', + 'value': 'str(obj.flow.id)' + }, + + # report + { + 'name': 'Report URL', + 'key': 'report_url', + 'value': 'obj.path' + }, + { + 'name': 'Report ID', + 'key': 'report_id', + 'value': 'str(obj.id)' + }, + + # test + { + 'name': 'Test ID', + 'key': 'test_id', + 'value': 'str(obj.id)' + }, + + # scan + { + 'name': 'Scan ID', + 'key': 'scan_id', + 'value': 'str(obj.id)' + }, + + # page + { + 'name': 'Page ID', + 'key': 'page_id', + 'value': 'str(obj.page.id)' + }, + { + 'name': 'Page URL', + 'key': 'page_url', + 'value': 'obj.page.page_url' + }, + + # site + { + 'name': 'Site ID', + 'key': 'site_id', + 'value': 'str(obj.site.id)' + }, + { + 'name': 'Site URL', + 'key': 'site_url', + 'value': 'obj.site.site_url' + }, +] + + + + + +# get definition +def get_definition(key: str=None, name: str=None) -> str: + """ + Finds the specific data definition based on the + key or name provided. + + Expects: { + "key" : str, + "name" : str, + } + + Returns -> "definition" dict, or None + """ + + # setting default + selected = None + + # iterate and search through definitions + for obj in definitions: + if obj['key'] == key or obj['name'] == name: + selected = obj + break + + # return definition + return selected \ No newline at end of file diff --git a/app/api/utils/devices.py b/app/api/utils/devices.py new file mode 100644 index 00000000..f964a001 --- /dev/null +++ b/app/api/utils/devices.py @@ -0,0 +1,256 @@ +# This is a ChatGPT generated list of devices +# https://chatgpt.com/c/6706e3f9-d6f0-8004-8564-12947cc76e2b + + + + +devices = [ + { + "id": "1", + "name": "Google Pixel 5", + "window_size": "393,851", + "user_agent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Mobile Safari/537.36", + "browser": "chrome", + "type": "mobile" + }, + { + "id": "2", + "name": "Samsung Galaxy S21", + "window_size": "412,915", + "user_agent": "Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Mobile Safari/537.36", + "browser": "chrome", + "type": "mobile" + }, + { + "id": "3", + "name": "iPhone 12 Pro", + "window_size": "390,844", + "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1", + "browser": "chrome", + "type": "mobile" + }, + { + "id": "4", + "name": "iPad Pro", + "window_size": "1024,1366", + "user_agent": "Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1", + "browser": "chrome", + "type": "tablet" + }, + { + "id": "5", + "name": "Samsung Galaxy Tab S7", + "window_size": "800,1280", + "user_agent": "Mozilla/5.0 (Linux; Android 10; SM-T870) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36", + "browser": "chrome", + "type": "tablet" + }, + { + "id": "6", + "name": "MacBook Pro 16", + "window_size": "1536,960", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36", + "browser": "chrome", + "type": "desktop" + }, + { + "id": "7", + "name": "Windows 10 PC", + "window_size": "1920,1080", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36", + "browser": "chrome", + "type": "desktop" + }, + { + "id": "8", + "name": "iMac 24-inch", + "window_size": "2560,1440", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36", + "browser": "chrome", + "type": "desktop" + }, + { + "id": "9", + "name": "Chromebook Pixel", + "window_size": "1280,850", + "user_agent": "Mozilla/5.0 (X11; CrOS x86_64 14092.54.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36", + "browser": "chrome", + "type": "desktop" + }, + { + "id": "10", + "name": "Google Pixel 5", + "window_size": "393,851", + "user_agent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/113.0.0 Mobile Safari/537.36", + "browser": "firefox", + "type": "mobile" + }, + { + "id": "11", + "name": "Samsung Galaxy S21", + "window_size": "412,915", + "user_agent": "Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/113.0.0 Mobile Safari/537.36", + "browser": "firefox", + "type": "mobile" + }, + { + "id": "12", + "name": "iPhone 12 Pro", + "window_size": "390,844", + "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1", + "browser": "firefox", + "type": "mobile" + }, + { + "id": "13", + "name": "iPad Pro", + "window_size": "1024,1366", + "user_agent": "Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1", + "browser": "firefox", + "type": "tablet" + }, + { + "id": "14", + "name": "Samsung Galaxy Tab S7", + "window_size": "800,1280", + "user_agent": "Mozilla/5.0 (Linux; Android 10; SM-T870) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/113.0.0 Safari/537.36", + "browser": "firefox", + "type": "tablet" + }, + { + "id": "15", + "name": "MacBook Pro 16", + "window_size": "1536,960", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/113.0.0 Safari/537.36", + "browser": "firefox", + "type": "desktop" + }, + { + "id": "16", + "name": "Windows 10 PC", + "window_size": "1920,1080", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/113.0.0 Safari/537.36", + "browser": "firefox", + "type": "desktop" + }, + { + "id": "17", + "name": "iMac 24-inch", + "window_size": "2560,1440", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/113.0.0 Safari/537.36", + "browser": "firefox", + "type": "desktop" + }, + { + "id": "18", + "name": "Chromebook Pixel", + "window_size": "1280,850", + "user_agent": "Mozilla/5.0 (X11; CrOS x86_64 14092.54.0) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/113.0.0 Safari/537.36", + "browser": "firefox", + "type": "desktop" + }, + { + "id": "19", + "name": "Google Pixel 5", + "window_size": "393,851", + "user_agent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Edg/113.0.0.0 Mobile Safari/537.36", + "browser": "edge", + "type": "mobile" + }, + { + "id": "20", + "name": "Samsung Galaxy S21", + "window_size": "412,915", + "user_agent": "Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Edg/113.0.0.0 Mobile Safari/537.36", + "browser": "edge", + "type": "mobile" + }, + { + "id": "21", + "name": "iPhone 12 Pro", + "window_size": "390,844", + "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1", + "browser": "edge", + "type": "mobile" + }, + { + "id": "22", + "name": "iPad Pro", + "window_size": "1024,1366", + "user_agent": "Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1", + "browser": "edge", + "type": "tablet" + }, + { + "id": "23", + "name": "Samsung Galaxy Tab S7", + "window_size": "800,1280", + "user_agent": "Mozilla/5.0 (Linux; Android 10; SM-T870) AppleWebKit/537.36 (KHTML, like Gecko) Edg/113.0.0.0 Safari/537.36", + "browser": "edge", + "type": "tablet" + }, + { + "id": "24", + "name": "MacBook Pro 16", + "window_size": "1536,960", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Edg/113.0.0.0 Safari/537.36", + "browser": "edge", + "type": "desktop" + }, + { + "id": "25", + "name": "Windows 10 PC", + "window_size": "1920,1080", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/113.0.0.0 Safari/537.36", + "browser": "edge", + "type": "desktop" + }, + { + "id": "26", + "name": "iMac 24-inch", + "window_size": "2560,1440", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Edg/113.0.0.0 Safari/537.36", + "browser": "edge", + "type": "desktop" + }, + { + "id": "27", + "name": "Chromebook Pixel", + "window_size": "1280,850", + "user_agent": "Mozilla/5.0 (X11; CrOS x86_64 14092.54.0) AppleWebKit/537.36 (KHTML, like Gecko) Edg/113.0.0.0 Safari/537.36", + "browser": "edge", + "type": "desktop" + } +] + + + + +# get device +def get_device(browser: str=None, name: str=None) -> str: + """ + Finds the specific device based on the + browser and name provided. + + Expects: { + "browser": str, + "name": str, + } + + Returns -> "device" dict + """ + + # setting default to 'Windows 10 PC' + selected = devices[6] + + # iterate and search through devices + for device in devices: + if device['browser'] == browser and device['name'] == name: + selected = device + break + + # return device + return selected + + + diff --git a/app/api/utils/driver.py b/app/api/utils/driver.py new file mode 100644 index 00000000..fd8d0850 --- /dev/null +++ b/app/api/utils/driver.py @@ -0,0 +1,341 @@ +from selenium import webdriver +from selenium.webdriver.common.actions.action_builder import ActionBuilder +from selenium.webdriver.firefox.options import Options +from selenium.webdriver.firefox.firefox_profile import FirefoxProfile +from .devices import get_device +from datetime import datetime +import time, os, sys, tempfile + + + + + + +def driver_init( + browser: str='chrome', + window_size: str='1920,1080', + device: str='Windows 10 PC', + script_timeout: int=30, + load_timeout: int=30, + wait_time: int=15, + pixel_ratio: int=1.0, + scale_factor: int=0.5 + ) -> object: + """ + Starts a new selenium driver instance + + Expects: { + 'browser' : str, + 'window_size' : str, + 'device' : str, + 'script_timeout': int, + 'load_timeout' : int, + 'wait_time' : int, + 'pixel_ratio' : int, + 'scale_factor' : int + } + + Returns -> driver object + """ + + # get userAgent + user_agent = get_device(browser, device)['user_agent'] + + # deciding on browser + # UserAgents are from utils/devices + if browser == 'chrome': + options = webdriver.ChromeOptions() + options.binary_location = os.environ.get('CHROME_BROWSER') + if browser == 'firefox': + options = webdriver.FirefoxOptions() + options.binary_location = os.environ.get('FIREFOX_BROWSER') + if browser == 'edge': + options = webdriver.EdgeOptions() + options.binary_location = os.environ.get('EDGE_BROWSER') + + # setting up browser configs + sizes = window_size.split(',') + width = int(sizes[0]) + height = int(sizes[1]) + emulation = { + "deviceMetrics": { + "width": width, + "height": height, + "pixelRatio": pixel_ratio + }, + "userAgent": user_agent + } + + # setting broswer options for chrome + if browser == 'chrome': + options.add_argument("--no-sandbox") + options.add_argument("disable-blink-features=AlertControlled") + options.add_argument("--headless") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("ignore-certificate-errors") + options.add_argument("--hide-scrollbars") + options.add_argument(f"--force-device-scale-factor={str(scale_factor)}") + options.add_argument(f"--user-agent={user_agent}") + options.set_capability("goog:loggingPrefs", {'performance': 'ALL'}) + options.page_load_strategy = 'none' + + # setting to mobile or tablet if reqeusted + if device == 'mobile' or device == 'tablet': + options.add_experimental_option("mobileEmulation", emulation) + + # init driver + driver = webdriver.Chrome(options=options) + + # setting broswer options for firefox + if browser == 'firefox': + # setting profile + temp_profile_dir = tempfile.mkdtemp() + ff_profile = FirefoxProfile(temp_profile_dir) + # adding arguments + options.add_argument("-headless") + options.page_load_strategy = 'none' + options.set_preference("accept_insecure_certs", True) + options.set_preference('layout.css.devPixelsPerPx', str(scale_factor)) + options.profile = ff_profile + + # setting to mobile if reqeusted + if device == 'mobile': + options.set_preference( + "general.useragent.override", f"userAgent={user_agent}" + ) + + # init driver + driver = webdriver.Firefox(options=options) + + # setting broswer options for edge + if browser == 'edge': + options.add_argument("--no-sandbox") + options.add_argument("disable-blink-features=AlertControlled") + options.add_argument("--headless") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("ignore-certificate-errors") + options.add_argument("--hide-scrollbars") + options.add_argument(f"--force-device-scale-factor={str(scale_factor)}") + options.add_argument(f"--user-agent={user_agent}") + options.set_capability("goog:loggingPrefs", {'performance': 'ALL'}) + options.page_load_strategy = 'none' + + # setting to mobile or tablet if reqeusted + if device == 'mobile' or device == 'tablet': + options.add_experimental_option("mobileEmulation", emulation) + + # init driver + driver = webdriver.Edge(options=options) + + + # resizing window + driver.maximize_window() + driver.set_window_size(width, height) + print(f'Using {browser} browser') + + return driver + + + + +def driver_test() -> None: + """ + Spins up a selenium driver instance and + tests to ensure it can access the browser and internet + + Returns -> None + """ + + print("Testing Selenium...") + message = 'Selenium was unable to start\n\n' + status = 'Failed' + + # testing selenium + try: + driver = driver_init() + driver.set_page_load_timeout(20) + driver.get('https://google.com') + title = driver.title + assert title == 'Google' + if title == 'Google': + status = 'Success' + message = 'Selenium installed and working \N{check mark} \n\n' + # log exception + except Exception as e: + print(e) + + # logging test results + sys.stdout.write( + '--- ' + status + ' ---\n'+ message + ) + + try: + quit_driver(driver) + sys.exit(0) + except: + pass + + return None + + + + +def driver_wait( + driver: object, + interval: int=1, + max_wait_time: int=30, + min_wait_time: int=3 + ) -> bool: + """ + Expects the driver instance and waits + for either the page to fully load or the max_wait_time + to expire before returning. + + Expects: { + 'driver' : object, + 'interval' : int, + 'max_wait_time' : int, + 'min_wait_time' : int + } + + Returns -> bool (True if page is loaded) + """ + + def interact_with_page(driver): + # simulate mouse movement + action = ActionBuilder(driver) + action.pointer_action.move_to_location(0, 0) + action.perform() + # wait for 1s + time.sleep(1) + action.pointer_action.move_to_location(0, 50) + action.perform() + return + + resolved = False + page_state = 'loading' + wait_time = 0 + + # min_wait_time before checking page status + time.sleep(int(min_wait_time)) + + while int(wait_time) < int(max_wait_time) and page_state != 'complete': + + # get current timestamp + pre_check_time = datetime.now() + + # wait 1 sec or sec + time.sleep(int(interval)) + + try: + page_state = driver.execute_script('return document.readyState') + except Exception as e: + print(e) + + # get time after waiting for script + post_check_time = datetime.now() + + # get seconds between checks + time_to_add = (post_check_time - pre_check_time).total_seconds() + + print(f'document state is {page_state}') + if page_state == 'complete': + resolved = True + + wait_time += time_to_add + + # interacting with page if available + if resolved: + interact_with_page(driver) + + return resolved + + + + +def get_data( + driver: object, + browser: str='chrome', + interval: int=1, + max_wait_time: int=30, + min_wait_time: int=3 + ) -> dict: + """ + Once the page has loaded, grabs the + page-source (html) and console-logs (logs). + + Expects: { + 'driver' : object, + 'browser' : str, + 'interval' : int, + 'max_wait_time' : int, + 'min_wait_time' : int + } + + Returns -> data = { + 'html' : str, + 'logs' : dict + } + """ + + # setting defaults + html = None + logs = [] + + # waiting for page to load + driver_wait( + driver=driver, + interval=interval, + max_wait_time=max_wait_time, + min_wait_time=min_wait_time + ) + + # get page_source from browser + try: + html = driver.page_source + except Exception as e: + print(e) + + # get console logs if notn firefox + if browser != 'firefox' : + try: + logs = driver.get_log('browser') + except Exception as e: + print(e) + + # formatting respones + data = { + "html": html, + "logs": logs + } + + return data + + + + +def quit_driver(driver: object) -> None: + """ + Quits and reaps all child processes in docker + + Returns -> None + """ + print('Quitting session: %s' % driver.session_id) + driver.quit() + try: + pid = True + while pid: + pid = os.waitpid(-1, os.WNOHANG) + print("Reaped child: %s" % str(pid)) + # avoid infinite loop cause pid value -> (0, 0) + try: + if pid[0] == 0: + pid = False + except: + pass + except ChildProcessError: + pass + + + + \ No newline at end of file diff --git a/app/api/utils/driver_p.py b/app/api/utils/driver_p.py deleted file mode 100644 index c9ccce82..00000000 --- a/app/api/utils/driver_p.py +++ /dev/null @@ -1,175 +0,0 @@ -from pyppeteer import launch -import time, os, numpy, json, sys, datetime, asyncio - - - -async def driver_init( - window_size='1920,1080', - wait_time=30, - ): - - sizes = window_size.split(',') - - options = { - 'executablePath': os.environ.get('CHROMIUM'), - 'args': [ - '--no-sandbox', - '--disable-dev-shm-usage', - f'--window-size={window_size}', - ], - 'defaultViewport': { - 'width': int(sizes[0]), - 'height': int(sizes[1]), - }, - 'timeout': wait_time * 1000 - } - - driver = await launch( - options=options, - headless=True, - handleSIGINT=False, - handleSIGTERM=False, - handleSIGHUP=False - ) - - return driver - - - - - -async def interact_with_page(page): - # simulate mouse movement - await page.mouse.move(0, 0) - await page.mouse.move(0, 100) - - return page - - - - - - -async def driver_test(*args, **options): - - print("Testing puppeteer instalation and integration...") - - try: - driver = await driver_init() - page = await driver.newPage() - await page.goto('https://google.com', {'waitUntil': 'networkidle0'}) - await interact_with_page(page) - title = await page.title() - assert title == 'Google' - if title == 'Google': - status = 'Success' - else: - status = 'Failed' - await driver.close() - except Exception as e: - print(e) - status = 'Failed' - - sys.stdout.write('--- ' + status + ' ---\n' - + 'Puppeteer installed and working \N{check mark} \n' - ) - - - - - - -async def get_data(url, configs, *args, **options): - sizes = configs['window_size'].split(',') - driver = await driver_init(window_size=configs['window_size']) - page = await driver.newPage() - - page_options = { - 'waitUntil': 'networkidle0', - 'timeout': configs['max_wait_time']*1000 - } - viewport = { - 'width': int(sizes[0]), - 'height': int(sizes[1]), - } - - userAgent = ( - "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 \ - (KHTML, like Gecko) Chrome/99.0.4812.0 Safari/537.36" - ) - - await page.setViewport(viewport) - - if configs['device'] == 'mobile': - await page.setUserAgent(userAgent) - - - logs = [] - def record_logs(log): - if log.type == 'error': - if '.js' in log.text: - source = 'javascript' - elif 'http' in log.text: - source = 'network' - else: - source = 'other' - log_obj = { - "level": "SEVERE", - "source": source, - "message": str(log.text), - "timestamp": int(datetime.datetime.now().timestamp() * 1000) - } - logs.append(log_obj) - elif log.type == 'warning': - if '.js' in log.text: - source = 'javascript' - elif 'http' in log.text: - source = 'network' - else: - source = 'other' - log_obj = { - "level": "WARNING", - "source": source, - "message": str(log.text), - "timestamp": int(datetime.datetime.now().timestamp() * 1000) - } - logs.append(log_obj) - - def record_network(request): - log_obj = { - "level": "SEVERE", - "source": "network", - "message": f'{request.failure()["errorText"]} {request.url}', - "timestamp": int(datetime.datetime.now().timestamp() * 1000) - } - logs.append(log_obj) - - def record_error(error): - err = str(error).split(' at ')[0] - log_obj = { - "level": "SEVERE", - "source": "javascript", - "message": f'{err}', - "timestamp": int(datetime.datetime.now().timestamp() * 1000) - } - logs.append(log_obj) - - - page.on('console', lambda log : record_logs(log)) - page.on('requestfailed', lambda request : record_network(request)) - page.on('pageerror', lambda error : record_error(error)) - - await page.goto(url, page_options) - - # await page.waitForNavigation(navWaitOpt) - await interact_with_page(page) - html = await page.content() - - await driver.close() - - data = { - 'html': html, - 'logs': logs, - } - - return data \ No newline at end of file diff --git a/app/api/utils/driver_s.py b/app/api/utils/driver_s.py deleted file mode 100644 index 582c3b0e..00000000 --- a/app/api/utils/driver_s.py +++ /dev/null @@ -1,167 +0,0 @@ -from selenium import webdriver -from selenium.webdriver.common.desired_capabilities import DesiredCapabilities -from selenium.webdriver import ActionChains -import time, os, numpy, json, sys - - - -def driver_init( - window_size='1920,1080', - device='desktop', - script_timeout=30, - load_timeout=30, - wait_time=15, - ): - - sizes = window_size.split(',') - - prefs = { - 'download.prompt_for_download': False, - 'download.extensions_to_open': '.zip', - 'safebrowsing.enabled': True - } - - mobile_emulation = { - "deviceMetrics": { "width": int(sizes[0]), "height": int(sizes[1]), "pixelRatio": 1.0 }, - "userAgent": ( - "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 \ - (KHTML, like Gecko) Chrome/99.0.4844.74 Mobile Safari/537.36" - ) - } - - chromedriver_path = os.environ.get("CHROMEDRIVER") - options = webdriver.ChromeOptions() - options.binary_location = os.environ.get('CHROMIUM') - options.add_argument("--no-sandbox") - options.add_argument("disable-blink-features=AutomationControlled") - options.add_experimental_option('prefs',prefs) - options.add_argument("start-maximized") - options.add_argument("--headless") - options.add_argument("--disable-dev-shm-usage") - options.add_argument("--window-size=%s" % window_size) - - if device == 'mobile': - options.add_experimental_option("mobileEmulation", mobile_emulation) - - caps = DesiredCapabilities.CHROME - caps['goog:loggingPrefs'] = {'performance': 'ALL'} - - driver = webdriver.Chrome(executable_path=chromedriver_path, options=options, desired_capabilities=caps) - driver.set_page_load_timeout(load_timeout) - driver.set_script_timeout(script_timeout) - driver.implicitly_wait(wait_time) - - - return driver - - -def driver_test(): - - print("Testing selenium instalation and integration...") - try: - driver = driver_init() - driver.get('https://google.com') - title = driver.title - assert title == 'Google' - if title == 'Google': - status = 'Success' - else: - status = 'Failed' - except Exception as e: - print(e) - status = 'Failed' - - sys.stdout.write('--- ' + status + ' ---\n' - + 'Selenium installed and working \N{check mark} \n' - ) - - quit_driver(driver) - sys.exit(0) - - - -def driver_wait(driver, interval=5, max_wait_time=30, min_wait_time=5): - """ - Pauses the driver until all network requests have been resolved - - --> Adding mouse interaction to load WP plugin rendered content - - returns once driver determines that all request have resolved or - total wait time exceeds max_wait_time - - """ - - def get_request_list(driver): - # get current snapshot of driver requests - requests = driver.get_log('performance') - r_list = [] - for r in requests: - network_log = json.loads(r["message"])["message"] - - # Checks if the current 'method' key has any Network related value. - if("Network.response" in network_log["method"] - or "Network.request" in network_log["method"] - or "Network.webSocket" in network_log["method"]): - - r_list.append(network_log) - - return r_list - - - def interact_with_page(driver): - # simulate mouse movement and click on tag - html_tag = driver.find_elements_by_tag_name('html')[0] - action = ActionChains(driver) - action.move_to_element(html_tag).perform() - return - - - resolved = False - wait_time = 0 - - # actions before comparing network logs - interact_with_page(driver) - time.sleep(min_wait_time) - - while not resolved and wait_time < max_wait_time: - # get first set of logs - list_one = get_request_list(driver=driver) - - # wait 5 sec or sec for request to resolve - time.sleep(interval) - - # get second set of logs - list_two = get_request_list(driver=driver) - - # check if logs are equal - resolved = numpy.array_equal(list_one, list_two) - - wait_time += interval - - return - - - - -def quit_driver(driver): - ''' - Quits and reaps all child processes in docker - ''' - print('Quitting session: %s' % driver.session_id) - driver.quit() - try: - pid = True - while pid: - pid = os.waitpid(-1, os.WNOHANG) - print("Reaped child: %s" % str(pid)) - - # avoid infinite loop cause pid value -> (0, 0) - try: - if pid[0] == 0: - pid = False - except: - pass - - - except ChildProcessError: - pass \ No newline at end of file diff --git a/app/api/utils/exporter.py b/app/api/utils/exporter.py new file mode 100644 index 00000000..e63c425a --- /dev/null +++ b/app/api/utils/exporter.py @@ -0,0 +1,121 @@ +from .driver import driver_init, driver_wait, quit_driver +from PIL import Image as I +from .alerts import sendgrid_email +from cursion import settings +import time, boto3, os + + + + + +def create_and_send_report_export(report_id: id, email: str, first_name: str) -> dict: + """ + Takes a screenshot of the `landing.report`, + save as a PDF, upload to s3 bucket, and then + send an email to the prospect that requested it. + + Expects the following: + 'report_id' : id of report/page being reported on + 'email' : str prospect's email address + 'first_name' : str prospect's first name + + Returns -> data { + 'success' : bool if process started successfully + 'error' : str any error msg from cursion server + } + """ + + # setting up s3 client + s3 = boto3.client( + 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), + aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), + region_name=str(settings.AWS_S3_REGION_NAME), + endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) + ) + + # init driver + driver = driver_init(scale_factor=1) + + # nav to report page + driver.get(f'{settings.LANDING_URL_ROOT}/report/{report_id}') + time.sleep(5) + + # setting screensize + full_page_height = driver.execute_script("return document.scrollingElement.scrollHeight;") + driver.set_window_size(1512, int(full_page_height)) + + # taking screenshot + driver.save_screenshot(f'{report_id}.png') + + # quitting driver + quit_driver(driver) + + # setting up paths + image = os.path.join(settings.BASE_DIR, f'{report_id}.png') + pdf = os.path.join(settings.BASE_DIR, f'{report_id}.pdf') + + # resizing image to remove excess | expected height => 2353 + img = I.open(image) + width, height = img.size + left = 0 + top = 85 + right = width + bottom = height - (330) + new_img_1 = img.crop((left, top, right, bottom)) + new_img_1.save(image, quality=95) + + # convert to pdf + img = I.open(image) + new_img_2 = img.convert('RGB') + new_img_2.save(pdf, quality=95) + + # uploading to s3 + remote_path = f'static/landing/reports/{report_id}.pdf' + report_url = f'{settings.AWS_S3_URL_PATH}/{remote_path}' + + # upload to s3 + with open(pdf, 'rb') as data: + s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), + remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': 'application/pdf'} + ) + + # removing local copies + os.remove(image) + os.remove(pdf) + + # setting up email to prospect + pre_content = 'The Cursion performance report you requested has finished processing. \ + Now, just click the link below to view and download the PDF.' + content = 'If you have any questions about the report or want deeper insights, feel free to book a short call with me here -> https://cursion.dev/booking' + subject = f'{first_name}, your Cursion Report is Ready' + title = f'{first_name}, your Cursion Report is Ready' + pre_header = f'{first_name}, your Cursion Report is Ready' + button_text = 'View Your Report' + email = email + object_url = report_url + signature = f'- Landon R | CEO @Cursion' + greeting = f'Hi {first_name},' + + message_obj = { + 'pre_content': pre_content, + 'content': content, + 'subject': subject, + 'title': title, + 'pre_header': pre_header, + 'button_text': button_text, + 'email': email, + 'object_url': object_url, + 'signature': signature, + 'greeting': greeting + } + + # sending email to prospect + data = sendgrid_email(message_obj) + + # returning data + return data + + + + + diff --git a/app/api/utils/flowr.py b/app/api/utils/flowr.py new file mode 100644 index 00000000..f9da0ff0 --- /dev/null +++ b/app/api/utils/flowr.py @@ -0,0 +1,847 @@ +from ..models import * +from .alerter import Alerter +from ..tasks import ( + create_caserun_bg, create_report_bg, + create_scan_bg, create_test_bg, + send_phone_bg, send_email_bg, + send_slack_bg, send_webhook_bg +) +from cursion import settings +from datetime import datetime, timezone +import time, uuid, json, boto3, os, requests, uuid, random + + + + + + +class Flowr(): + """ + Executes a `FlowRun` based on the state of + the `FlowRun` instance. + + Expects: { + 'flowrun_id' : str, + } + + - Use `Flowr.run_next()` to run next step in `FlowRun` + + Returns -> Flow instance + """ + + + + + def __init__(self, flowrun_id: str=None) -> object: + + # retrieve flowrun + self.flowrun_id = flowrun_id + self.flowrun = FlowRun.objects.get(id=flowrun_id) + + # constants for tasks that require 'object_id' + self.alert_types = ['webhook', 'email', 'phone', 'slack'] + + + + + def build_timestamp(self) -> object: + # build timestamp + return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f') + + + + + def get_timestamp(self, timestamp: str=None) -> object: + """ + Formats the 'timestamp' if not None + + Expects: { + timestamp: str + } + + Returns: datetime object + """ + + # format for timestamp + f = '%Y-%m-%d %H:%M:%S.%f' + + if timestamp: + # clean timestamp str + clean_str = timestamp.replace('T', ' ').replace('Z', '') + # format date str as datetime obj + return datetime.strptime(clean_str, f) + + # return None if no timestamp + return None + + + + + def get_current_step(self) -> dict: + """ + Finds the most recently completed step + + Expects: None + + Returns: { + 'index' : int, + 'node' : dict + } + """ + + # copy all "completed" self.flowrun.nodes + nodes = [ + node for node in self.flowrun.nodes \ + if (node['data']['time_completed'] and not node['data']['finalized']) + ] + + # sort nodes/steps by time_completed + sorted_nodes = sorted( + nodes, + key=lambda x: self.get_timestamp(x['data']['time_completed']), + reverse=True + ) + + # get current_node + current_node = sorted_nodes[0] if len(sorted_nodes) > 0 else None + + # get index of current_node + index = 0 + if current_node: + for node in self.flowrun.nodes: + if current_node['id'] == node['id']: + break + index+=1 + + # return data + data = { + 'index': index, + 'node': current_node + } + return data + + + + + def get_last_node_id(self) -> str: + """ + Sorts the nodes by time_completed + and largets ID. + + Expects: None + + Returns: ID + """ + # copy all "completed" self.flowrun.nodes + nodes = [ + node for node in self.flowrun.nodes if (node['data']['time_completed']) + ] + + # sort nodes/steps by decending int(id) + sorted_nodes = sorted( + nodes, + key=lambda x: int(x['data']['id']), + reverse=True + ) + + # return first node in sorted nodes + return sorted_nodes[0]['data']['id'] + + + + + def get_edge_by_target(self, target: str=None) -> dict: + """ + Retrieves the self.flowrun.edge[] that matched the + passed 'target' id + + Expects: { + 'target': str + } + + Returns: { + 'index': str, + 'edge': dict + } + """ + + # find target + index = 0 + for e in self.flowrun.edges: + if e['target'] == target: + return { + 'index': index, + 'edge': e + } + index+=1 + return {'index': None, 'edge': None} + + + + + def get_edges_by_source(self, source: str=None) -> dict: + """ + Retrieves the self.flowrun.edges[] that matched the + passed 'source' id + + Expects: { + 'source': str + } + + Returns: [{ + 'index': str, + 'edge': dict + },] + """ + + # find + index = 0 + edges = [] + for e in self.flowrun.edges: + if e['source'] == source: + edges.append({ + 'index': index, + 'edge': e + }) + index+=1 + return edges + + + + + def get_node_by_id(self, id: str=None) -> dict: + """ + Retrieves the self.flowrun.node[] that matched the + passed 'id' + + Expects: { + 'id': str + } + + Returns: { + 'index': str, + 'node': dict + } + """ + + # find node by id + index = 0 + for n in self.flowrun.nodes: + if n['id'] == id: + return { + 'index': index, + 'node': n + } + index+=1 + return {'index': None, 'node': None} + + + + + def objects_are_complete(self, object_list: list=[]) -> bool: + """ + Iterates through the object_list of a given node + and returns True if all object.status != 'working' + + Expects: { + 'object_list': list + } + + Returns: bool + """ + if len(object_list) == 0: + return True + for obj in object_list: + if obj['status'] == 'working': + return False + return True + + + + + def check_all_working_nodes(self, ignore_ids: list=[]) -> None: + """ + Check all objs.time_complete for each working node. + if node is `working` and all obj.time_complete + are not None: update node & edge with status.'passed' + + Expects: + "ignore_ids": list of node.ids to ignore + + Returns: None + """ + # get fresh flowrun obj + flowrun = FlowRun.objects.get(id=self.flowrun_id) + + # copy flowrun.nodes & flowrun.edges + nodes = flowrun.nodes + edges = flowrun.edges + + # set index + i = 0 + + # loop through all nodes + for node in flowrun.nodes: + if node['data']['status'] == 'working' and node['id'] not in ignore_ids: + + # loop through each "working" obj + if node['data']['objects']: + + # set defaults + status = 'passed' + + for obj in node['data']['objects']: + if obj['status'] == 'working': + + # catch all objs that have no id yet (i.e. `Test` objs) + if obj['id'] is None: + status = 'working' + continue + + # get object using Alerter() + o = Alerter( + object_id=obj['id'], + task_type=node['data']['task_type'] + ).get_object() + + # check time_complete + if o is not None: + try: + if o.time_completed is None: + status = 'working' + except Exception as e: + print(e) + pass + + # update node if changed + if status != 'working': + + # update node + j = 0 + final_status = status + for obj in nodes[i]['data'].get('objects', []): + + # get current obj status + _status = nodes[i]['data']['objects'][j]['status'] + + # update obj status + nodes[i]['data']['objects'][j]['status'] = _status if _status != 'working' else 'passed' + + # update final_status if obj failed + if _status == 'failed': + final_status = 'failed' + j += 1 + + # add final node status + nodes[i]['data']['status'] = final_status + nodes[i]['data']['time_completed'] = self.build_timestamp() + + # update edge + edge = self.get_edge_by_target(nodes[i]['data']['id']) + if edge['edge']: + edges[edge['index']]['animated'] = True if final_status == 'working' else False + edges[edge['index']]['style'] = {'stroke': "#60a5fa"} if final_status == 'working' else None + + # increment + i += 1 + + # update flowrun + flowrun.nodes = nodes + flowrun.edges = edges + flowrun.save() + + return None + + + + + def finalize_node(self, index: int=None) -> None: + """ + Updates the node matching the 'index' with + 'finalized' = True, then updates self.flowrun + + Expects: { + 'index': int + } + + Returns: None + """ + + # copy and update + nodes = self.flowrun.nodes + nodes[int(index)]['data']['finalized'] = True + + # save to DB + self.flowrun.nodes = nodes + self.flowrun.save() + return None + + + + + def run_next(self) -> None: + """ + Checks for the next step and executes + if current step has completed. + + Expects: None + + Returns: `FlowRun` object + """ + + # check if flowrun is complete + if self.flowrun.time_completed: + # return early + print('flowrun is complete') + return self.flowrun + + + # get last completed node or None + current_data = self.get_current_step() + + + # check if FlowRun is just starting + if current_data['node'] is None and \ + self.flowrun.nodes[0]['data']['status'] == 'queued': + + # create step_data for first step + step_data = { + 'index': 0, + 'node': self.flowrun.nodes[0] + } + + # create alert obj if needed for first job + alert_obj = { + 'parent': str(self.flowrun_id), + 'id': str(self.flowrun_id), + 'status': 'working' + } + objs = [alert_obj,] if step_data['node']['data']['task_type'] in self.alert_types else [] + + # run first step + print('running first step') + self.execute_step(step_data=step_data, objects=objs) + return self.flowrun + + + # catch updates without a current_node + if current_data['node'] is None: + return self.flowrun + + + # check for node conditions given not 'queued' or 'working' + if current_data['node']['data']['conditions'] and \ + current_data['node']['data']['status'] != 'failed': + + # starting conditons buliding & execution + print('building conditons') + + # finialize node + self.finalize_node(index=current_data['index']) + + # set defaults + true_outcomes = [] + false_outcomes = [] + run_as_cumulative = False + false_child_ran = False + true_child_ran = False + + # iterate through the objects and run conditions for each + for obj_data in current_data['node']['data'].get('objects', []): + + # get obj using Alerter + obj = Alerter( + object_id=obj_data['id'], + task_type=current_data['node']['data']['task_type'] + ).get_object() + + # build and execute conditions + conditons = Alerter( + expressions=current_data['node']['data']['conditions'] + ).build_expressions() + + # evaluate conditons + outcome = eval(f'True if ({conditons}) else False') + + # create new fake parent ID + parentID = uuid.uuid4() + + # sorting + if outcome == True: + true_outcomes.append({ + 'parent': str(parentID), + 'id': obj_data['id'], + 'status': 'working' + }) + if outcome == False: + false_outcomes.append({ + 'parent': str(parentID), + 'id': obj_data['id'], + 'status': 'working' + }) + + # get child edges + edges = self.get_edges_by_source(current_data['node']['id']) + children = [self.get_node_by_id(e['edge']['target']) for e in edges] + + # establish true/false child nodes + true_child = None + false_child = None + for c in children: + if c['node']['data']['start_if'] == True: + true_child = c + if c['node']['data']['start_if'] == False: + false_child = c + + # run true_child if true_outcomes exists + if len(true_outcomes) > 0: + true_task = true_child['node']['data']['task_type'] if true_child else None + # sleeping random for DB + time.sleep(random.uniform(1, 5)) + self.execute_step( + step_data=true_child, + objects=true_outcomes if true_task in self.alert_types else [] + ) + + # run false_child if false_outcomes exists + if len(false_outcomes) > 0: + false_task = false_child['node']['data']['task_type'] if false_child else None + # sleeping random for DB + time.sleep(random.uniform(1, 5)) + self.execute_step( + step_data=false_child, + objects=false_outcomes if false_task in self.alert_types else [] + ) + + # ending section + return self.flowrun + + + # get and execute next step if current_node status is 'passed' + if current_data['node']['data']['status'] == 'passed': + + # finialize node + self.finalize_node(index=current_data['index']) + + # get child edges + edges = self.get_edges_by_source(current_data['node']['id']) + children = [self.get_node_by_id(e['edge']['target']) for e in edges] + + # children length should be <= 1 since + # current_node.conditions == None + if len(children) == 1: + if children[0] is not None: + next_step = children[0] + print('running next step after "PASSED" non-conditional step') + objs = [] + if next_step['node']['data']['task_type'] in self.alert_types: + objs = current_data['node']['data'].get('objects', []) + self.execute_step(step_data=next_step, objects=objs) + return self.flowrun + + # check for other working nodes + for node in FlowRun.objects.get(id=self.flowrun_id).nodes: + if node['data']['status'] == 'working': + return self.flowrun + + # if no children and no node is 'working' + # then end flowrun as 'passed' and update logs + logs = self.flowrun.logs + logs.append({ + 'timestamp':self.build_timestamp(), + 'message': ( + f'flowrun completed with status: ✅ PASSED' + ), + 'step': self.get_last_node_id() + }) + # sort logs + logs = sorted(logs, key=lambda l: int(l['step']),) + + # update flowrun + self.flowrun.time_completed = self.build_timestamp() + self.flowrun.status = 'passed' + self.flowrun.logs = logs + self.flowrun.save() + + # run alert if requested + alert_id = current_data['node']['data'].get('alert_id') + if alert_id: + Alerter(alert_id=alert_id, object_id=str(self.flowrun_id)).run_alert() + + # return flowrun + return self.flowrun + + + # mark flowrun as `complete` and `failed` if + # current_node status is 'failed' & 'end_on_fail' is True + if current_data['node']['data']['status'] == 'failed': + + # finialize node + self.finalize_node(index=current_data['index']) + + # define failed log + failed_log = { + 'timestamp':self.build_timestamp(), + 'message': ( + f'flowrun completed with status: ❌ FAILED' + ), + 'step': self.get_last_node_id() + } + + # end flowrun if requested + if self.flowrun.configs.get('end_on_fail', True): + + print('--- ending run early due to failure ---') + + # update logs + logs = self.flowrun.logs + logs.append(failed_log) + # sort logs + logs = sorted(logs, key=lambda l: (int(l['step'])),) + + # update & end flowrun + self.flowrun.time_completed = self.build_timestamp() + self.flowrun.status = 'failed' + self.flowrun.logs = logs + self.flowrun.save() + + # run alert if requested + alert_id = current_data['node']['data'].get('alert_id') + if alert_id: + Alerter(alert_id=alert_id, object_id=str(self.flowrun_id)).run_alert() + + # return flowrun + return self.flowrun + + # get child edges + edges = self.get_edges_by_source(current_data['node']['id']) + children = [self.get_node_by_id(e['edge']['target']) for e in edges] + + # children length should be <= 1 since + # current_node.conditions == None + if len(children) == 1: + if children[0] is not None: + next_step = children[0] + print('running next step after "FAILED" non-conditional step') + objs = [] + if next_step['node']['data']['task_type'] in self.alert_types: + objs = current_data['node']['data'].get('objects', []) + self.execute_step(step_data=next_step, objects=objs) + return self.flowrun + + # check for other working nodes + for node in FlowRun.objects.get(id=self.flowrun_id).nodes: + if node['data']['status'] == 'working': + return self.flowrun + + # if no children and no node is 'working' + # then end flowrun as 'failed' and update logs + logs = self.flowrun.logs + logs.append(failed_log) + # sort logs + logs = sorted(logs, key=lambda l: int(l['step']),) + + # update & end flowrun + self.flowrun.time_completed = self.build_timestamp() + self.flowrun.status = 'failed' + self.flowrun.logs = logs + self.flowrun.save() + + # run alert if requested + alert_id = current_data['node']['data'].get('alert_id') + if alert_id: + Alerter(alert_id=alert_id, object_id=str(self.flowrun_id)).run_alert() + + # return flowrun + return self.flowrun + + + + + def execute_step(self, step_data: dict=None, objects: list=None) -> None: + """ + Executes the `step` with associated job. + + Expects: { + 'step_data': { + 'index': str, + 'node' : dict + }, + 'objects': list + } + + Returns: None + """ + + if step_data is None: + print('no step_data provided - returning early') + return + + # get step/node data & task_type + node_data = step_data['node']['data'] + task_type = node_data['task_type'] + node_index = step_data['index'] + parent_data = None if node_index == 0 else self.get_node_by_id(node_data['parentId']) + message = ( + f'starting job ID: {node_data["id"]} ' + + f'| job type is [ {task_type.upper()} ]' + ) + + # update self.flowrun logs, nodes, & edges + self.flowrun = FlowRun.objects.get(id=self.flowrun_id) + nodes = self.flowrun.nodes + edges = self.flowrun.edges + logs = self.flowrun.logs + + # update current node + nodes[step_data['index']]['data']['status'] = 'working' + nodes[step_data['index']]['data']['time_started'] = self.build_timestamp() + nodes[step_data['index']]['data']['objects'] = objects + + # update current edge if not first step + if step_data['index'] != 0: + edge_index = self.get_edge_by_target(target=node_data['id'])['index'] + edges[edge_index]['animated'] = True + edges[edge_index]['style'] = {'stroke': "#60a5fa"} + + # update current logs + logs.append({ + 'timestamp':self.build_timestamp(), + 'message': message, + 'step': node_data['id'] + }) + + # sort logs + logs = sorted(logs, key=lambda l: int(l['step']),) + + # save updates + self.flowrun.nodes = nodes + self.flowrun.edges = edges + self.flowrun.logs = logs + self.flowrun.save() + + # build common data + scope = 'account' + configs = node_data['configs'] + flowrun_id = str(self.flowrun.id) + account_id = str(self.flowrun.account.id) + types = node_data.get('type') + resources = [{ + 'str': self.flowrun.site.site_url, + 'id': str(self.flowrun.site.id), + 'type': 'site' + },] + + + # create new scan + if task_type == 'scan': + create_scan_bg.delay( + scope = scope, + resources = resources, + account_id = account_id, + type = types, + configs = configs, + flowrun_id = flowrun_id, + node_index = node_index + ) + + # create new test + if task_type == 'test': + create_test_bg.delay( + scope = scope, + resources = resources, + account_id = account_id, + type = types, + configs = configs, + threshold = node_data['threshold'], + flowrun_id = flowrun_id, + node_index = node_index + ) + + # create new caserun + if task_type == 'case': + create_caserun_bg.delay( + scope = scope, + resources = resources, + account_id = account_id, + case_id = node_data['case_id'], + updates = node_data['updates'], + configs = configs, + flowrun_id = flowrun_id, + node_index = node_index + ) + + # create new report + if task_type == 'report': + create_report_bg.delay( + scope = scope, + resources = resources, + account_id = account_id, + configs = configs, + flowrun_id = flowrun_id, + node_index = node_index + ) + + # send phone notification + if task_type == 'phone': + send_phone_bg.delay( + account_id = account_id, + objects = objects, + phone_number = node_data['phone_number'], + body = node_data['message'], + flowrun_id = flowrun_id, + node_index = node_index + ) + + # send slack notification + if task_type == 'slack': + send_slack_bg.delay( + account_id = account_id, + objects = objects, + body = node_data['message'], + flowrun_id = flowrun_id, + node_index = node_index + ) + + # send email notification + if task_type == 'email': + send_email_bg.delay( + account_id = account_id, + objects = objects, + message_obj = { + 'plain_text' : True, + 'email' : node_data['email'], + 'subject' : node_data['subject'], + 'content' : node_data['message'] + }, + flowrun_id = flowrun_id, + node_index = node_index + ) + + # send webhook notification + if task_type == 'webhook': + send_webhook_bg.delay( + account_id = account_id, + objects = objects, + request_type = node_data['request_type'], + url = node_data['uri'], + headers = node_data['headers'], + payload = node_data['payload'], + flowrun_id = flowrun_id, + node_index = node_index + ) + + + # check all objs.time_complete for each "working" node. + # if node is `working` and all obj.time_complete + # are not None: update node with status.'passed' + self.check_all_working_nodes(ignore_ids=[node_data['id']]) + + + # returning + return None + + + + + diff --git a/app/api/utils/image.py b/app/api/utils/image.py deleted file mode 100644 index a6f88768..00000000 --- a/app/api/utils/image.py +++ /dev/null @@ -1,1163 +0,0 @@ -from .driver_s import driver_init, driver_wait, quit_driver -from .driver_p import driver_init as driver_init_p -from selenium import webdriver -from ..models import Site, Scan, Test, Mask -from selenium.webdriver.chrome.options import Options -from django.forms.models import model_to_dict -from django.core.serializers.json import DjangoJSONEncoder -from sewar.full_ref import uqi, mse, ssim, msssim, psnr, ergas, vifp, rase, sam, scc -from scanerr import settings -from PIL import Image as I, ImageChops, ImageStat -from pyppeteer import launch -from datetime import datetime -from asgiref.sync import sync_to_async -import time, os, sys, json, uuid, boto3, \ - statistics, shutil, numpy, cv2 - - - - - -class Image(): - """ - High level Image handler used to compare screenshots of - a website and retrieve single one-page screenshots. - Also known as VRT or Visual Regression Testing. - Contains five methods scan(), scan_p(), test(), - screenshot(), and screenshot_p(). The _p appendage - denotes using Puppeteer as the webdriver: - - def scan(site, driver=None) -> grabs multiple - screenshots of the website and uploads - them to s3. - - - def test(test=) -> compares each - screenshot in the two scans and records - a score out of 100% - - - def screeshot(site, driver=None) -> grabs single - screenshot of the site and uploads it to s3 - - """ - - - def __init__(self): - - # scripts - self.set_jquery = ( - """ - var jq = document.createElement('script'); - jq.src = "https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"; - document.getElementsByTagName('head')[0].appendChild(jq); - """ - ) - - - self.mask_function = ( - """ - (function($){ - $.fn.overlayMask = function (action) { - var mask = this.find('.overlay-mask'); - - // Create the required mask - - if (!mask.length) { - this.css({ - position: 'relative' - }); - mask = $('
'); - mask.css({ - position: 'absolute', - width: '100%', - height: '100%', - color: 'green', - backgroundColor: 'green', - top: '0px', - left: '0px', - zIndex: 100, - }).appendTo(this); - } - - // Act based on params - - if (!action || action === 'show') { - mask.show(); - } else if (action === 'hide') { - mask.hide(); - } - - return this; - }; - })(jQuery) - - """ - ) - - - - - def check_timeout(self, timeout, start_time): - """ - Checks to see if the current time exceedes the alotted timeout. - - returns -> True if timeout exceeded - """ - - current = datetime.now() - diff = current - start_time - if diff.total_seconds() >= timeout: - print('exceeded timeout') - return True - else: - return False - - - - - def scan(self, site, configs, driver=None,): - """ - Grabs multiple screenshots of the website and uploads - them to s3. - """ - - # setup boto3 configurations - s3 = boto3.client( - 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), - aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), - region_name=str(settings.AWS_S3_REGION_NAME), - endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) - ) - - # initialize driver if not passed as param - driver_present = True - if not driver: - driver = driver_init() - driver_present = False - - - # request site_url - driver.get(site.site_url) - - # waiting for network requests to resolve - driver_wait( - driver=driver, - interval=int(configs.get('interval', 5)), - min_wait_time=int(configs.get('min_wait_time', 10)), - max_wait_time=int(configs.get('max_wait_time', 30)), - ) - - - if configs.get('disable_animations') == True: - # inserting animation pausing script - try: - driver.execute_script("const styleElement = document.createElement('style');styleElement.setAttribute('id','style-tag');const styleTagCSSes = document.createTextNode('*,:after,:before{-webkit-transition:none!important;-moz-transition:none!important;-ms-transition:none!important;-o-transition:none!important;transition:none!important;-webkit-transform:none!important;-moz-transform:none!important;-ms-transform:none!important;-o-transform:none!important;-webkit-animation:none!important;animation:none!important;transform:none!important;transition-delay:0s!important;transition-duration:0s!important;animation-delay:-0.0001s!important;animation-duration:0s!important;animation-play-state:paused!important;caret-color:transparent!important;color-adjust:exact!important;}');styleElement.appendChild(styleTagCSSes);document.head.appendChild(styleElement);") - except: - print('cannot pause animations') - - # inserting video pausing scripts - try: - driver.execute_script("const video = document.querySelectorAll('video').forEach(vid => vid.pause());") - except: - print('cannnot pause videos') - - # mask all listed ids - if configs.get('mask_ids') is not None and configs.get('mask_ids') != '': - ids = configs.get('mask_ids').split(',') - for id in ids: - try: - driver.execute_script(f"document.getElementById('{id}').style.visibility='hidden';") - print('masked an element') - except: - print('cannot find element via id provided') - - - # mask all Global mask ids that are active - active_masks = Mask.objects.filter(active=True) - if len(active_masks) != 0: - for mask in active_masks: - try: - driver.execute_script(f"document.getElementById('{mask.mask_id}').style.visibility='hidden';") - print('masked an element') - except: - print('cannot find element via global mask id provided') - - - # scroll one frame at a time and capture screenshot - image_array = [] - index = 0 - last_height = -1 - bottom = False - start_time = datetime.now() - while not bottom: - - # checking if maxed out time - if self.check_timeout(configs.get('timeout', 300), start_time): - break - - # scroll single frame - if index != 0: - # driver.execute_script("window.scrollBy(0, window.innerHeight);") - driver.execute_script("window.scrollBy(0, document.documentElement.clientHeight);") - time.sleep(int(configs.get('min_wait_time', 10))) - - # get current position and compare to previous - new_height = driver.execute_script("return window.pageYOffset + document.documentElement.clientHeight") - height_diff = new_height - last_height - if height_diff > 20: - last_height = new_height - pic_id = uuid.uuid4() - - # waiting for network requests to resolve - driver_wait( - driver=driver, - interval=int(configs.get('interval', 5)), - min_wait_time=int(configs.get('min_wait_time', 10)), - max_wait_time=int(configs.get('max_wait_time', 30)), - ) - - # get screenshot - driver.save_screenshot(f'{pic_id}.png') - image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') - remote_path = f'static/sites/{site.id}/{pic_id}.png' - root_path = settings.AWS_S3_URL_PATH - image_url = f'{root_path}/{remote_path}' - - # upload to s3 - with open(image, 'rb') as data: - s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), - remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} - ) - # remove local copy - os.remove(image) - - # create image obj and add to list - img_obj = { - "index": index, - "id": str(pic_id), - "url": image_url, - "path": remote_path, - } - - image_array.append(img_obj) - - index += 1 - - else: - bottom = True - - if not driver_present: - quit_driver(driver) - - return image_array - - - - - - - def _scan(self, site, configs, driver=None,): - """ - Grabs multiple screenshots of the website and uploads - them to s3 as one package. - """ - - # setup boto3 configurations - s3 = boto3.client( - 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), - aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), - region_name=str(settings.AWS_S3_REGION_NAME), - endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) - ) - - # initialize driver if not passed as param - driver_present = True - if not driver: - driver = driver_init() - driver_present = False - - - # request site_url - driver.get(site.site_url) - - # waiting for network requests to resolve - driver_wait( - driver=driver, - interval=int(configs.get('interval', 5)), - min_wait_time=int(configs.get('min_wait_time', 10)), - max_wait_time=int(configs.get('max_wait_time', 30)), - ) - - if configs.get('disable_animations') == True: - # inserting animation pausing script - try: - driver.execute_script("const styleElement = document.createElement('style');styleElement.setAttribute('id','style-tag');const styleTagCSSes = document.createTextNode('*,:after,:before{-webkit-transition:none!important;-moz-transition:none!important;-ms-transition:none!important;-o-transition:none!important;transition:none!important;-webkit-transform:none!important;-moz-transform:none!important;-ms-transform:none!important;-o-transform:none!important;-webkit-animation:none!important;animation:none!important;transform:none!important;transition-delay:0s!important;transition-duration:0s!important;animation-delay:-0.0001s!important;animation-duration:0s!important;animation-play-state:paused!important;caret-color:transparent!important;color-adjust:exact!important;}');styleElement.appendChild(styleTagCSSes);document.head.appendChild(styleElement);") - except: - print('cannot pause animations') - - # inserting video pausing scripts - try: - driver.execute_script("const video = document.querySelectorAll('video').forEach(vid => vid.pause());") - except: - print('cannnot pause videos') - - - # mask all listed ids - if configs.get('mask_ids') is not None and configs.get('mask_ids') != '': - ids = configs.get('mask_ids').split(',') - for id in ids: - try: - driver.execute_script(f"document.getElementById('{id}').style.visibility='hidden';") - print('masked an element') - except: - print('cannot find element via id provided') - - - # mask all Global mask ids that are active - active_masks = Mask.objects.filter(active=True) - if len(active_masks) != 0: - for mask in active_masks: - try: - driver.execute_script(f"document.getElementById('{mask.mask_id}').style.visibility='hidden';") - print('masked an element') - except: - print('cannot find element via global mask id provided') - - - # vertically concats two images - def add_images(im1, im2): - im1 = I.open(im1) - im2 = I.open(im2) - new_img = I.new('RGB', (im1.width, im1.height + im2.height)) - new_img.paste(im1, (0, 0)) - new_img.paste(im2, (0, im1.height)) - return new_img - - - # scroll one frame at a time and capture screenshot - final_img = None - image_array = [] - index = 0 - last_height = -1 - bottom = False - start_time = datetime.now() - while not bottom: - - # checking if maxed out time - if self.check_timeout(configs.get('timeout', 300), start_time): - break - - # scroll single frame - if index != 0: - # driver.execute_script("window.scrollBy(0, window.innerHeight);") - driver.execute_script("window.scrollBy(0, document.documentElement.clientHeight);") - time.sleep(int(configs.get('min_wait_time', 10))) - - # get current position and compare to previous - new_height = driver.execute_script("return window.pageYOffset + document.documentElement.clientHeight") - height_diff = new_height - last_height - if height_diff > 20: - last_height = new_height - pic_id = uuid.uuid4() - - # waiting for network requests to resolve - driver_wait( - driver=driver, - interval=int(configs.get('interval', 5)), - min_wait_time=int(configs.get('min_wait_time', 10)), - max_wait_time=int(configs.get('max_wait_time', 30)), - ) - - # get screenshot - driver.save_screenshot(f'{pic_id}.png') - image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') - - # adding new image to bottom of existing image (if not index = 0) - pic_id_2 = uuid.uuid4() - if index != 0 and final_img is not None: - add_images(final_img, image).save(f'{pic_id_2}.png') - os.remove(final_img) - final_img = os.path.join(settings.BASE_DIR, f'{pic_id_2}.png') - else: - I.open(image).save(f'{pic_id_2}.png') - final_img = os.path.join(settings.BASE_DIR, f'{pic_id_2}.png') - - # remove local copy - os.remove(image) - - index += 1 - - else: - bottom = True - - - remote_path = f'static/sites/{site.id}/{pic_id_2}.png' - root_path = settings.AWS_S3_URL_PATH - image_url = f'{root_path}/{remote_path}' - - # upload to s3 - with open(final_img, 'rb') as data: - s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), - remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} - ) - - - # create image obj and add to list - img_obj = { - "index": 0, - "id": str(pic_id_2), - "url": image_url, - "path": remote_path, - } - - image_array.append(img_obj) - - # remove local copy - os.remove(final_img) - - if not driver_present: - quit_driver(driver) - - return image_array - - - - - - - - async def scan_p(self, site, configs): - """ - Using Puppeteer, grabs multiple screenshots of the website and uploads - them to s3. - """ - - # setup boto3 configurations - s3 = boto3.client( - 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), - aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), - region_name=str(settings.AWS_S3_REGION_NAME), - endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) - ) - - driver = await driver_init_p(window_size=configs.get('window_size', '1920,1080'), wait_time=configs.get('max_wait_time', 30)) - page = await driver.newPage() - - sizes = configs.get('window_size', '1920,1080').split(',') - is_mobile = False - if configs.get('device') == 'mobile': - is_mobile = True - - page_options = { - 'waitUntil': 'networkidle0', - 'timeout': configs.get('max_wait_time', 30)*1000 - } - - viewport = { - 'width': int(sizes[0]), - 'height': int(sizes[1]), - 'isMobile': is_mobile, - } - - userAgent = ( - "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 \ - (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36" - ) - - emulate_options = { - 'viewport': viewport, - 'userAgent': userAgent - } - - if configs.get('device') == 'mobile': - await page.emulate(emulate_options) - else: - await page.setViewport(viewport) - - # requesting site url - await page.goto(site.site_url, page_options) - - - if configs.get('disable_animations') == True: - # inserting animation pausing script - try: - await page.evaluate("const styleElement = document.createElement('style');styleElement.setAttribute('id','style-tag');const styleTagCSSes = document.createTextNode('*,:after,:before{-webkit-transition:none!important;-moz-transition:none!important;-ms-transition:none!important;-o-transition:none!important;transition:none!important;-webkit-transform:none!important;-moz-transform:none!important;-ms-transform:none!important;-o-transform:none!important;-webkit-animation:none!important;animation:none!important;transform:none!important;transition-delay:0s!important;transition-duration:0s!important;animation-delay:-0.0001s!important;animation-duration:0s!important;animation-play-state:paused!important;caret-color:transparent!important;color-adjust:exact!important;}');styleElement.appendChild(styleTagCSSes);document.head.appendChild(styleElement);") - except: - print('cannot pause animations') - - # pausing videos - try: - videos = await page.querySelectorAll('video') - for vid in videos: - await page.evaluate('(vid) => vid.pause()', vid) - except Exception as e: - print(e) - - - # mask all listed ids - if configs.get('mask_ids') is not None and configs.get('mask_ids') != '': - ids = configs.get('mask_ids').split(',') - for id in ids: - try: - await page.evaluate(f"document.getElementById('{id}').style.visibility='hidden';") - print('masked an element') - except: - print('cannot find element via id provided') - - - # mask all Global mask ids that are active - @sync_to_async - def get_active_global_masks(): - masks = Mask.objects.filter(active=True) - active_masks = [] - if len(masks) > 0: - for mask in masks: - active_masks.append(mask.id) - return active_masks - - active_masks = await get_active_global_masks() - - for mask in active_masks: - try: - await page.evaluate(f"document.getElementById('{mask}').style.visibility='hidden';") - print('masked an element') - except: - print('cannot find element via global mask id provided') - - - # scroll one frame at a time and capture screenshot - image_array = [] - index = 0 - last_height = -1 - bottom = False - start_time = datetime.now() - while not bottom: - - # checking if maxed out time - if self.check_timeout(configs.get('timeout', 300), start_time): - break - - # scroll single frame - if index != 0: - await page.evaluate("window.scrollBy(0, document.documentElement.clientHeight);") - time.sleep(int(configs.get('min_wait_time', 10))) - - # get current position and compare to previous - new_height = await page.evaluate("window.pageYOffset + document.documentElement.clientHeight") - height_diff = new_height - last_height - if height_diff > 20: - last_height = new_height - pic_id = uuid.uuid4() - - # interact with and wait for page to load - await page.mouse.move(0, 0) - await page.mouse.move(0, 100) - time.sleep(configs.get('min_wait_time', 10)) - - - # get screenshot - await page.screenshot({'path': f'{pic_id}.png'}) - - image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') - remote_path = f'static/sites/{site.id}/{pic_id}.png' - root_path = settings.AWS_S3_URL_PATH - image_url = f'{root_path}/{remote_path}' - - # upload to s3 - with open(image, 'rb') as data: - s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), - remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} - ) - # remove local copy - os.remove(image) - - # create image obj and add to list - img_obj = { - "index": index, - "id": str(pic_id), - "url": image_url, - "path": remote_path, - } - - image_array.append(img_obj) - - index += 1 - - else: - bottom = True - - - await driver.close() - - return image_array - - - - - - - - - async def _scan_p(self, site, configs): - """ - Using Puppeteer, grabs multiple screenshots of the website and uploads - them to s3 as a single image. - """ - - # setup boto3 configurations - s3 = boto3.client( - 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), - aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), - region_name=str(settings.AWS_S3_REGION_NAME), - endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) - ) - - driver = await driver_init_p(window_size=configs.get('window_size', '1920,1080'), wait_time=configs.get('max_wait_time', 30)) - page = await driver.newPage() - - sizes = configs.get('window_size', '1920,1080').split(',') - is_mobile = False - if configs.get('device') == 'mobile': - is_mobile = True - - page_options = { - 'waitUntil': 'networkidle0', - 'timeout': configs.get('max_wait_time', 30)*1000 - } - - viewport = { - 'width': int(sizes[0]), - 'height': int(sizes[1]), - 'isMobile': is_mobile, - } - - userAgent = ( - "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 \ - (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36" - ) - - emulate_options = { - 'viewport': viewport, - 'userAgent': userAgent - } - - if configs.get('device') == 'mobile': - await page.emulate(emulate_options) - else: - await page.setViewport(viewport) - - # requesting site url - await page.goto(site.site_url, page_options) - - if configs.get('disable_animations') == True: - # inserting animation pausing script - try: - await page.evaluate("const styleElement = document.createElement('style');styleElement.setAttribute('id','style-tag');const styleTagCSSes = document.createTextNode('*,:after,:before{-webkit-transition:none!important;-moz-transition:none!important;-ms-transition:none!important;-o-transition:none!important;transition:none!important;-webkit-transform:none!important;-moz-transform:none!important;-ms-transform:none!important;-o-transform:none!important;-webkit-animation:none!important;animation:none!important;transform:none!important;transition-delay:0s!important;transition-duration:0s!important;animation-delay:-0.0001s!important;animation-duration:0s!important;animation-play-state:paused!important;caret-color:transparent!important;color-adjust:exact!important;}');styleElement.appendChild(styleTagCSSes);document.head.appendChild(styleElement);") - except: - print('cannot pause animations') - - # pausing videos - try: - videos = await page.querySelectorAll('video') - for vid in videos: - await page.evaluate('(vid) => vid.pause()', vid) - except Exception as e: - print(e) - - # mask all listed ids - if configs.get('mask_ids') is not None and configs.get('mask_ids') != '': - ids = configs.get('mask_ids').split(',') - for id in ids: - try: - await page.evaluate(f"document.getElementById('{id}').style.visibility='hidden';") - print('masked an element') - except: - print('cannot find element via id provided') - - - # mask all Global mask ids that are active - @sync_to_async - def get_active_global_masks(): - masks = Mask.objects.filter(active=True) - active_masks = [] - for mask in masks: - active_masks.append(mask.id) - return active_masks - - active_masks = await get_active_global_masks() - - for mask in active_masks: - try: - await page.evaluate(f"document.getElementById('{mask}').style.visibility='hidden';") - print('masked an element') - except: - print('cannot find element via global mask id provided') - - - # vertically concats two images - @sync_to_async - def add_images(im1, im2): - im1 = I.open(im1) - im2 = I.open(im2) - new_img = I.new('RGB', (im1.width, im1.height + im2.height)) - new_img.paste(im1, (0, 0)) - new_img.paste(im2, (0, im1.height)) - return new_img - - - # scroll one frame at a time and capture screenshot - final_img = None - image_array = [] - index = 0 - last_height = -1 - bottom = False - start_time = datetime.now() - while not bottom: - - # checking if maxed out time - if self.check_timeout(configs.get('timeout', 300), start_time): - break - - # scroll single frame - if index != 0: - await page.evaluate("window.scrollBy(0, document.documentElement.clientHeight);") - time.sleep(int(configs.get('min_wait_time', 10))) - - # get current position and compare to previous - new_height = await page.evaluate("window.pageYOffset + document.documentElement.clientHeight") - height_diff = new_height - last_height - if height_diff > 20: - last_height = new_height - pic_id = uuid.uuid4() - - # interact with and wait for page to load - await page.mouse.move(0, 0) - await page.mouse.move(0, 100) - time.sleep(configs.get('min_wait_time', 10)) - - - # get screenshot - await page.screenshot({'path': f'{pic_id}.png'}) - image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') - - # adding new image to bottom of existing image (if not index = 0) - pic_id_2 = uuid.uuid4() - if index != 0 and final_img is not None: - new_img = await add_images(final_img, image) - new_img.save(f'{pic_id_2}.png') - os.remove(final_img) - final_img = os.path.join(settings.BASE_DIR, f'{pic_id_2}.png') - else: - I.open(image).save(f'{pic_id_2}.png') - final_img = os.path.join(settings.BASE_DIR, f'{pic_id_2}.png') - - # remove local copy - os.remove(image) - - index += 1 - - else: - bottom = True - - - remote_path = f'static/sites/{site.id}/{pic_id_2}.png' - root_path = settings.AWS_S3_URL_PATH - image_url = f'{root_path}/{remote_path}' - - # upload to s3 - with open(final_img, 'rb') as data: - s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), - remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} - ) - - - # create image obj and add to list - img_obj = { - "index": 0, - "id": str(pic_id_2), - "url": image_url, - "path": remote_path, - } - - image_array.append(img_obj) - - # remove local copy - os.remove(final_img) - - - await driver.close() - - return image_array - - - - - - - - - - - def test(self, test, index=None): - """ - Compares each screenshot between the two scans and records - a score out of 100%. - - Compairsons used : - - Structral Similarity Index (ssim) - - PIL ImageChop Differences, Ratio - - cv2 ORB Brute-force Matcher, Ratio - - - """ - - # setup boto3 configurations - s3 = boto3.client( - 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), - aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), - region_name=str(settings.AWS_S3_REGION_NAME), - endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) - ) - - # setup temp dirs - if not os.path.exists(os.path.join(settings.BASE_DIR, f'temp/{test.id}')): - os.makedirs(os.path.join(settings.BASE_DIR, f'temp/{test.id}')) - - # temp root - temp_root = os.path.join(settings.BASE_DIR, f'temp/{test.id}') - - # loop through and download each img in scan and compare it. - pre_scan_images = test.pre_scan.images - img_test_results = [] - scores = [] - i = 0 - - if index is not None: - pre_scan_images = [test.pre_scan.images[index]] - i = index - - for pre_img_obj in pre_scan_images: - - # getting pre_scan image - pre_img_path = os.path.join(temp_root, f'{pre_img_obj["id"]}.png') - with open(pre_img_path, 'wb') as data: - s3.download_fileobj(str(settings.AWS_STORAGE_BUCKET_NAME), pre_img_obj["path"], data) - - # open with PIL Image library - pre_img = I.open(pre_img_path) - # convert to array - pre_img_array = numpy.array(pre_img) - - # getting post_scan image - try: - post_img_obj = test.post_scan.images[i] - except: - post_img_obj = None - - if post_img_obj is not None: - post_img_path = os.path.join(temp_root, f'{post_img_obj["id"]}.png') - with open(post_img_path, 'wb') as data: - s3.download_fileobj(str(settings.AWS_STORAGE_BUCKET_NAME), post_img_obj["path"], data) - - # open with PIL Image library - post_img = I.open(post_img_path) - # convert to array - post_img_array = numpy.array(post_img) - - - # test images with PIL - def pil_score(pre_img, post_img): - try: - if (pre_img.mode != post_img.mode) \ - or (pre_img.size != post_img.size) \ - or (pre_img.getbands() != post_img.getbands()): - raise Exception('images are not comparable') - - # Generate diff image in memory. - diff_img = ImageChops.difference(pre_img, post_img) - - # Calculate difference as a ratio. - stat = ImageStat.Stat(diff_img) - diff_ratio = (sum(stat.mean) / (len(stat.mean) * 255)) * 100 - pil_img_score = (100 - diff_ratio) - # print(f'PIL score -> {pil_img_score}') - return pil_img_score - - except Exception as e: - print(e) - - - # test with cv2 - def cv2_score(pre_img_array, post_img_array): - try: - orb = cv2.ORB_create() - - # detect keypoints and descriptors - kp_a, desc_a = orb.detectAndCompute(pre_img_array, None) - kp_b, desc_b = orb.detectAndCompute(post_img_array, None) - - # define the bruteforce matcher object - bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) - - # perform matches. - matches = bf.match(desc_a, desc_b) - - # Look for similar regions with distance < 20. (from 0 to 100) - similar_regions = [i for i in matches if i.distance < 20] - if len(matches) == 0: - cv2_img_score = 100 - else: - cv2_img_score = (len(similar_regions) / len(matches)) * 100 - # print(f'cv2 -> {cv2_img_score}') - - return cv2_img_score - - except Exception as e: - print(e) - - - - # test images - try: - img_score_tupple = ssim(pre_img_array, post_img_array) - img_score_list = list(img_score_tupple) - ssim_img_score = statistics.fmean(img_score_list) * 100 - # print(f'ssim -> {ssim_img_score}') - - pil_img_score = pil_score(pre_img, post_img) - # print(f'pil -> {pil_img_score}') - - cv2_img_score = cv2_score(pre_img_array, post_img_array) - # print(f'cv2 -> {cv2_img_score}') - - img_score = ((ssim_img_score * 2) + (pil_img_score * 1) + (cv2_img_score * 5)) / 8 - # print(f'img_score ==> {img_score}') - - except Exception as e: - print(e) - img_score = None - - # create img test obj and add to array - img_test_obj = { - "index": i, - "pre_img": pre_img_obj, - "post_img": post_img_obj, - "score": img_score, - } - - img_test_results.append(img_test_obj) - scores.append(img_score) - - # remove local copies - if post_img_obj is not None: - try: - os.remove(post_img_path) - except Exception as e: - print(e) - try: - os.remove(pre_img_path) - except Exception as e: - print(e) - - i += 1 - - # remove temp dir - shutil.rmtree(temp_root) - - # averaging scores and storing in images_delta obj - try: - avg_score = statistics.fmean(scores) - except: - avg_score = None - - images_delta = { - "average_score": avg_score, - "images": img_test_results, - } - - return images_delta - - - - - - - - def screenshot(self, site=None, url=None, configs=None, driver=None): - """ - Grabs single screenshot of the website and uploads - it to s3. - """ - - # setup boto3 configurations - s3 = boto3.client( - 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), - aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), - region_name=str(settings.AWS_S3_REGION_NAME), - endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) - ) - - if not configs: - configs = { - "interval": 5, - "window_size": "1920,1080", - "max_wait_time": 60, - "min_wait_time": 10, - "device": "desktop" - } - - # initialize driver if not passed as param - if not driver: - driver = driver_init(window_size=configs.get('window_size', '1920,1080'), device=configs.get('device')) - - - # get or create site data - if site is None: - site_id = uuid.uuid4() - site_url = url - else: - site_id = site.id - site_url = site.site_url - - # request site_url - driver.get(site_url) - - - # wait for site to fully load - driver_wait( - driver=driver, - interval=int(configs.get('interval', 5)), - min_wait_time=int(configs.get('min_wait_time', 10)), - max_wait_time=int(configs.get('max_wait_time', 30)), - ) - - # grab screenshot - pic_id = uuid.uuid4() - driver.save_screenshot(f'{pic_id}.png') - image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') - remote_path = f'static/sites/{site_id}/{pic_id}.png' - root_path = settings.AWS_S3_URL_PATH - image_url = f'{root_path}/{remote_path}' - - # upload to s3 - with open(image, 'rb') as data: - s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), - remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} - ) - # remove local copy - os.remove(image) - - # create image obj and add to list - img_obj = { - "id": str(pic_id), - "url": image_url, - "path": remote_path, - } - - # quit driver - quit_driver(driver) - - return img_obj - - - - async def screenshot_p(self, site=None, url=None, configs=None): - """ - Using Puppeteer, grabs single screenshot of the website and uploads - it to s3. - """ - - # setup boto3 configurations - s3 = boto3.client( - 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), - aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), - region_name=str(settings.AWS_S3_REGION_NAME), - endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) - ) - - if not configs: - configs = { - "interval": 5, - "driver": "puppeteer", - "device": "desktop", - "window_size": "1920,1080", - "max_wait_time": 60, - "min_wait_time": 10 - } - - driver = await driver_init_p(window_size=configs.get('window_size', '1920,1080'), wait_time=configs.get('max_wait_time', 30)) - page = await driver.newPage() - - sizes = configs.get('window_size', '1920,1080').split(',') - is_mobile = False - if configs.get('device') == 'mobile': - is_mobile = True - - page_options = { - 'waitUntil': 'networkidle0', - 'timeout': configs.get('max_wait_time', 30)*1000 - } - - viewport = { - 'width': int(sizes[0]), - 'height': int(sizes[1]), - 'isMobile': is_mobile, - } - - userAgent = ( - "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 \ - (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36" - ) - - emulate_options = { - 'viewport': viewport, - 'userAgent': userAgent - } - - if configs.get('device') == 'mobile': - await page.emulate(emulate_options) - else: - await page.setViewport(viewport) - - # get or create site data - if site is None: - site_id = uuid.uuid4() - site_url = url - else: - site_id = site.id - site_url = site.site_url - - # request site_url - await page.goto(site_url, page_options) - - # interact with and wait for page to load - await page.mouse.move(0, 0) - await page.mouse.move(0, 100) - time.sleep(configs.get('min_wait_time', 10)) - - # get screenshot - pic_id = uuid.uuid4() - await page.screenshot({'path': f'{pic_id}.png'}) - await driver.close() - image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') - remote_path = f'static/sites/{site_id}/{pic_id}.png' - root_path = settings.AWS_S3_URL_PATH - image_url = f'{root_path}/{remote_path}' - - # upload to s3 - with open(image, 'rb') as data: - s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), - remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} - ) - # remove local copy - os.remove(image) - - # create image obj and add to list - img_obj = { - "id": str(pic_id), - "url": image_url, - "path": remote_path, - } - - return img_obj \ No newline at end of file diff --git a/app/api/utils/imager.py b/app/api/utils/imager.py new file mode 100644 index 00000000..d23d2751 --- /dev/null +++ b/app/api/utils/imager.py @@ -0,0 +1,632 @@ +from .driver import driver_init, driver_wait, quit_driver +from ..models import Site, Scan, Test, Mask +from skimage.metrics import structural_similarity +from cursion import settings +from PIL import Image as I, ImageChops, ImageStat +from datetime import datetime +from asgiref.sync import sync_to_async +import time, os, sys, json, uuid, boto3, \ + statistics, shutil, numpy, cv2 + + + + + + +class Imager(): + """ + High level Image handler used to compare screenshots of + a website. + + Also known as VRT or Visual Regression Testing. + Contains two methods scan() & test(): + + def scan_vrt(driver=None) -> using selenium + grabs multiple screenshots of the website + and uploads them to s3. + + def test_vrt(test=) -> compares each + screenshot in the two scans and records + a score out of 100% + """ + + + + + def __init__(self, scan: object=None): + + # main scan object + self.scan = scan + + # main image_array for scans + self.image_array = [] + + # setup boto3 configurations + self.s3 = boto3.client( + 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), + aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), + region_name=str(settings.AWS_S3_REGION_NAME), + endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) + ) + + # scripts + self.pause_video_script = ( + """ + document.querySelectorAll('video').forEach(vid => vid.pause()); + document.querySelectorAll('video').forEach(vid => vid.currentTime=0); + """ + ) + self.set_jquery = ( + """ + var jq = document.createElement('script'); + jq.src = "https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"; + document.getElementsByTagName('head')[0].appendChild(jq); + """ + ) + self.pause_animations_script = ( + """ + const styleElement = document.createElement('style');styleElement.setAttribute('id','style-tag'); + const styleTagCSSes = document.createTextNode('*,:after,:before{-webkit-transition:none!important;-moz-transition:none!important;-ms-transition:none!important;-o-transition:none!important;transition:none!important;-webkit-transform:none!important;-moz-transform:none!important;-ms-transform:none!important;-o-transform:none!important;-webkit-animation:none!important;animation:none!important;transform:none!important;transition-delay:0s!important;transition-duration:0s!important;animation-delay:-0.0001s!important;animation-duration:0s!important;animation-play-state:paused!important;caret-color:transparent!important;color-adjust:exact!important;}'); + styleElement.appendChild(styleTagCSSes); + document.head.appendChild(styleElement); + """ + ) + + + + + def check_timeout(self, timeout: int, start_time: str) -> bool: + """ + Checks to see if the current time exceedes the alotted timeout. + + Returns -> True if timeout exceeded + """ + current = datetime.now() + diff = current - start_time + if diff.total_seconds() >= int(timeout): + print('exceeded timeout') + return True + else: + return False + + + + + def add_images(self, im1: object, im2: object) -> object: + """ + Joins img1 and im2 vertically and saves as "new_img" + + Returns -> new_img + """ + im1 = I.open(im1) + im2 = I.open(im2) + new_img = I.new('RGB', (im1.width, im1.height + im2.height)) + new_img.paste(im1, (0, 0)) + new_img.paste(im2, (0, im1.height)) + return new_img + + + + + def save_image(self, pic_id: str, image: object) -> None: + """ + Upload image to s3, save info as image_obj, + add image_obj to image_array, & remove image file + + Returns -> None + """ + remote_path = f'static/sites/{self.scan.site.id}/{self.scan.page.id}/{self.scan.id}/{pic_id}.png' + root_path = settings.AWS_S3_URL_PATH + image_url = f'{root_path}/{remote_path}' + + # upload to s3 + with open(image, 'rb') as data: + self.s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), + remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} + ) + + # create image obj and add to list + img_obj = { + "index": 0, + "id": str(pic_id), + "url": image_url, + "path": remote_path, + } + self.image_array.append(img_obj) + + print(f'adding {img_obj["url"]} to image_array') + + # remove local copy + os.remove(image) + + return None + + + + + def scan_vrt(self, driver: object=None) -> list: + """ + Grabs full length screenshots of the website and uploads + them to s3. + + Expects: { + 'driver': object + } + + Returns -> self.image_array list + """ + + # initialize driver if not passed as param + driver_present = True + if not driver: + driver = driver_init( + browser=self.scan.configs.get('browser', 'chrome'), + window_size=self.scan.configs.get('window_size', '1920,1080'), + device=self.scan.configs.get('device', 'desktop'), + ) + driver_present = False + + # request page_url + driver.get(self.scan.page.page_url) + + # waiting for network requests to resolve + driver_wait( + driver=driver, + interval=int(self.scan.configs.get('interval', 5)), + min_wait_time=int(self.scan.configs.get('min_wait_time', 10)), + max_wait_time=int(self.scan.configs.get('max_wait_time', 30)), + ) + + # defining browser demesions + sizes = self.scan.configs.get('window_size', '1920,1080').split(',') + + # calculating and auto setting page height + if self.scan.configs.get('auto_height', True): + + # get scroll_height, client_height & set window_size + scroll_height = driver.execute_script("return document.documentElement.scrollHeight;") + client_height = driver.execute_script("return document.documentElement.clientHeight;") + + # trying to match "document.body.clientHeight" + # and "document.body.scrollHeight" + # iterate 3 times or untill height_diff is less than 20 + i = 0 + success = False + while not success and i < 4: + + # set window_size + driver.set_window_size(int(sizes[0]), (int(scroll_height))) + + # scroll down and up + driver.execute_script(f"window.scrollBy(0, {client_height});") + time.sleep(1) + driver.execute_script(f"window.scrollBy(0, -{client_height});") + + # get client & new scroll height + client_height = driver.execute_script("return document.documentElement.clientHeight;") + new_scroll_height = driver.execute_script("return document.documentElement.scrollHeight;") + + # get difference between full page height and new scrolled position + height_diff = int(new_scroll_height) - int(client_height) + + # re-set window size + print(f'adding {height_diff} to full_page_height') + scroll_height += height_diff if height_diff > 0 else 0 + + # checking difference + if height_diff < 20: + success = True + + # increment + i += 1 + + + if self.scan.configs.get('disable_animations') == True: + # inserting animation pausing script + try: + driver.execute_script(self.pause_animations_script) + except: + print('cannot pause animations') + + # inserting video pausing scripts + try: + driver.execute_script(self.pause_video_script) + except: + print('cannnot pause videos') + + # mask all listed ids + if self.scan.configs.get('mask_ids') is not None and self.scan.configs.get('mask_ids') != '': + ids = self.scan.configs.get('mask_ids').split(',') + for id in ids: + try: + driver.execute_script(f"document.getElementById('{id}').style.visibility='hidden';") + print('masked an element') + except: + print('cannot find element via id provided') + + # mask all Global mask ids that are active + active_masks = Mask.objects.filter(active=True) + if len(active_masks) != 0: + for mask in active_masks: + try: + driver.execute_script(f"document.getElementById('{mask.mask_id}').style.visibility='hidden';") + print('masked an element') + except: + print('cannot find element via global mask id provided') + + # scroll one frame at a time and capture screenshot + final_img = None + index = 0 + last_height = -1 + bottom = False + start_time = datetime.now() + while not bottom: + + # checking if maxed out time + if self.check_timeout(self.scan.configs.get('timeout', 300), start_time): + break + + # scroll single frame if not first frame and not auto_height + if index != 0: + driver.execute_script("window.scrollBy(0, document.documentElement.clientHeight);") + time.sleep(int(self.scan.configs.get('min_wait_time', 10))) + + # get current position and compare to previous + new_height = driver.execute_script("return window.pageYOffset + document.documentElement.clientHeight") + height_diff = new_height - last_height + print(f'new_height => {new_height} | height_diff => {height_diff}') + + if height_diff > 20: + last_height = new_height + pic_id = uuid.uuid4() + + # waiting for network requests to resolve + driver_wait( + driver=driver, + interval=int(self.scan.configs.get('interval', 5)), + min_wait_time=int(self.scan.configs.get('min_wait_time', 10)), + max_wait_time=int(self.scan.configs.get('max_wait_time', 30)), + ) + + # get screenshot + driver.save_screenshot(f'{pic_id}.png') + image = os.path.join(settings.BASE_DIR, f'{pic_id}.png') + + # resizing image to remove duplicate portions + if index != 0: + img = I.open(image) + width, height = img.size + left = 0 + top = height - ((height_diff/2)) # divide by 2 for "driver.scale_factor" + right = width + botm = height + new_img = img.crop((left, top, right, botm)) + new_img.save(image, quality=100) + + # adding new image to bottom of existing image (if not index = 0) + pic_id_2 = uuid.uuid4() + if index != 0 and final_img is not None: + self.add_images(final_img, image).save(f'{pic_id_2}.png') + os.remove(final_img) + final_img = os.path.join(settings.BASE_DIR, f'{pic_id_2}.png') + else: + I.open(image).save(f'{pic_id_2}.png') + final_img = os.path.join(settings.BASE_DIR, f'{pic_id_2}.png') + + os.remove(image) + index += 1 + + else: + bottom = True + + # saving image + self.save_image(pic_id=pic_id_2, image=final_img) + + # clean up + if not driver_present: + quit_driver(driver) + + # return images + return self.image_array + + + + + def test_vrt(self, test: object, index: int=None) -> dict: + """ + Compares each screenshot between the two scans and records + a score out of 100%. + + Compairsons used : + - Structral Similarity Index (ssim) + - PIL ImageChop Differences, Ratio + - cv2 ORB Brute-force Matcher, Ratio + + Expects: { + 'test': object, + 'index': int, + } + + Returns -> data: { + 'average_score' : float(0-100), + 'images' : dict, + } + """ + + # setup temp dirs + if not os.path.exists(os.path.join(settings.BASE_DIR, f'temp/{test.id}')): + os.makedirs(os.path.join(settings.BASE_DIR, f'temp/{test.id}')) + + # temp root + temp_root = os.path.join(settings.BASE_DIR, f'temp/{test.id}') + + # loop through and download each img in scan and compare it. + pre_scan_images = test.pre_scan.images + img_test_results = [] + scores = [] + i = 0 + + if index is not None: + pre_scan_images = [test.pre_scan.images[index]] + i = index + + # catching user error when scan_type + # did not include 'vrt' + if pre_scan_images is None: + images_delta = { + "average_score": None, + "images": None, + } + return images_delta + + + for pre_img_obj in pre_scan_images: + + # getting pre_scan image + pre_img_path = os.path.join(temp_root, f'{pre_img_obj["id"]}.png') + with open(pre_img_path, 'wb') as data: + self.s3.download_fileobj(str(settings.AWS_STORAGE_BUCKET_NAME), pre_img_obj["path"], data) + + # getting post_scan image + try: + post_img_obj = test.post_scan.images[i] + except: + post_img_obj = None + + if post_img_obj is not None: + post_img_path = os.path.join(temp_root, f'{post_img_obj["id"]}.png') + with open(post_img_path, 'wb') as data: + self.s3.download_fileobj(str(settings.AWS_STORAGE_BUCKET_NAME), post_img_obj["path"], data) + + # open images with PIL Image library + post_img = I.open(post_img_path) + pre_img = I.open(pre_img_path) + + # check and reformat image sizes if necessary + pre_img_w, pre_img_h = pre_img.size + post_img_w, post_img_h = post_img.size + + # pre_img is longer + if pre_img_h > post_img_h: + print(f'pre_img is larger, adjusting...') + new_pre_img = pre_img.crop((0, 0, pre_img_w, post_img_h)).convert(mode=post_img.mode) + new_pre_img.save(pre_img_path, quality=100) + pre_img = I.open(pre_img_path) + # post_img is longer + if post_img_h > pre_img_h: + print(f'post_img is larger, adjusting...') + new_post_img = post_img.crop((0, 0, post_img_w, pre_img_h)).convert(mode=pre_img.mode) + new_post_img.save(post_img_path, quality=100) + post_img = I.open(post_img_path) + + + # build two new images with differences highlighted + def highlight_diffs(pre_img_path, post_img_path, index): + ''' + Returns -> two new images with highlights & float(ssim_score) + ''' + # Load the images + image1 = cv2.imread(pre_img_path) + image2 = cv2.imread(post_img_path) + + # Convert the images to grayscale + gray1 = cv2.cvtColor(image1, cv2.COLOR_BGR2GRAY) + gray2 = cv2.cvtColor(image2, cv2.COLOR_BGR2GRAY) + + # Compute the SSIM map + (ssim_score, diff) = structural_similarity(gray1, gray2, full=True) + + # Highlight the differences + diff = (diff * 255).astype("uint8") + + # Threshold the difference map + _, thresh = cv2.threshold(diff, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) + + # Find contours of the differences + contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + # Draw rectangles around the differences + for contour in contours: + (x, y, w, h) = cv2.boundingRect(contour) + cv2.rectangle(image1, (x, y), (x+w, y+h), (0, 255, 0), 2) + cv2.rectangle(image2, (x, y), (x+w, y+h), (0, 255, 0), 2) + + # Save the output images + img_1_id = uuid.uuid4() + img_2_id = uuid.uuid4() + cv2.imwrite(temp_root + f"/{img_1_id}.png", image1) + cv2.imwrite(temp_root + f"/{img_2_id}.png", image2) + img_objs = save_images(img_1_id, img_2_id, index) + + data = { + "img_objs": img_objs, + "ssim_score": ssim_score + } + + return data + + + # saving old images to new test.id path + def save_images(pre_img_id, post_img_id, index): + image_ids = [pre_img_id, post_img_id] + img_objs = [] + for img_id in image_ids: + image = os.path.join(temp_root, f'{img_id}.png') + remote_path = f'static/sites/{test.page.site.id}/{test.page.id}/{test.id}/{img_id}.png' + root_path = settings.AWS_S3_URL_PATH + image_url = f'{root_path}/{remote_path}' + + # upload to s3 + with open(image, 'rb') as data: + self.s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), + remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "image/png"} + ) + + # building img obj + obj = { + "id": str(img_id), + "url": image_url, + "path": remote_path, + "index": index, + } + img_objs.append(obj) + + return img_objs + + + # test images with PIL + def pil_score(pre_img, post_img): + try: + if (pre_img.mode != post_img.mode) \ + or (pre_img.size != post_img.size) \ + or (pre_img.getbands() != post_img.getbands()): + raise Exception('images are not comparable') + + # Generate diff image in memory. + diff_img = ImageChops.difference(pre_img, post_img) + + # Calculate difference as a ratio. + stat = ImageStat.Stat(diff_img) + diff_ratio = (sum(stat.mean) / (len(stat.mean) * 255)) * 100 + pil_img_score = (100 - diff_ratio) + # print(f'PIL score -> {pil_img_score}') + return pil_img_score + + except Exception as e: + print(e) + + + # test with cv2 + def cv2_score(pre_img, post_img): + try: + orb = cv2.ORB_create() + + # convert to array + pre_img_array = numpy.array(pre_img) + post_img_array = numpy.array(post_img) + + # detect keypoints and descriptors + kp_a, desc_a = orb.detectAndCompute(pre_img_array, None) + kp_b, desc_b = orb.detectAndCompute(post_img_array, None) + + # define the bruteforce matcher object + bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) + + # perform matches. + matches = bf.match(desc_a, desc_b) + + # Look for similar regions with distance < 20. (from 0 to 100) + similar_regions = [i for i in matches if i.distance < 20] + if len(matches) == 0: + cv2_img_score = 100 + else: + cv2_img_score = (len(similar_regions) / len(matches)) * 100 + # print(f'cv2 -> {cv2_img_score}') + + return cv2_img_score + + except Exception as e: + print(e) + + + + # test images + try: + # generating new highlighted images and score via ssim + ssim_results = highlight_diffs(pre_img_path, post_img_path, i) + pre_img_diff = ssim_results['img_objs'][0] + post_img_diff = ssim_results['img_objs'][1] + + # ssim scoring + ssim_img_score = ssim_results['ssim_score'] * 100 + + # pillow scoring + pil_img_score = pil_score(pre_img, post_img) + + # pixel perfect scoring + cv2_img_score = cv2_score(pre_img, post_img) + + # weighted average + img_score = ((ssim_img_score * 2) + (pil_img_score * 1) + (cv2_img_score * 5)) / 8 + + # saving old images to test.id path + old_imgs = save_images(pre_img_obj['id'], post_img_obj['id'], i) + pre_img = old_imgs[0] + post_img = old_imgs[1] + + except Exception as e: + print(e) + img_score = None + pre_img = None + post_img = None + pre_img_diff = None + post_img_diff = None + + # create img test obj and add to array + img_test_obj = { + "index": i, + "pre_img": pre_img, + "post_img": post_img, + "pre_img_diff": pre_img_diff, + "post_img_diff": post_img_diff, + "score": img_score, + } + + img_test_results.append(img_test_obj) + scores.append(img_score) + + # remove local copies + if post_img_obj is not None: + try: + os.remove(post_img_path) + except Exception as e: + print(e) + try: + os.remove(pre_img_path) + except Exception as e: + print(e) + + i += 1 + + # remove temp dir + shutil.rmtree(temp_root) + + # averaging scores and storing in images_delta obj + try: + avg_score = statistics.fmean(scores) + except: + avg_score = None + + # formatting response + images_delta = { + "average_score": avg_score, + "images": img_test_results, + } + + # returning response + return images_delta + + + + + diff --git a/app/api/utils/issuer.py b/app/api/utils/issuer.py new file mode 100644 index 00000000..14f75936 --- /dev/null +++ b/app/api/utils/issuer.py @@ -0,0 +1,269 @@ +from ..models import * +from cursion import settings +from openai import OpenAI +import time, os, json, uuid, random, boto3 + + + + + + +class Issuer(): + """ + Generate new `Issue` for the passed 'test' or 'caserun'. + + Expects: { + 'test' : object, + 'caserun' : object, + } + + Use `Issuer.build_issue()` to generate new `Issue` + + Returns -> None + """ + + + + + def __init__( + self, + test: object=None, + caserun: object=None, + ): + + # main objects + self.test = test + self.caserun = caserun + + # init GPT client + self.gpt_client = OpenAI( + api_key=settings.GPT_API_KEY, + ) + + + + + def build_issue(self): + """ + Creates a new `Issue` based on the info + from the passed "self.test" or "self.caserun" + + Expects: None + + Returns -> `Issue` + """ + + # defining top level attrs + title = None + details = None + labels = None + account = self.test.page.account if self.test else self.caserun.account + trigger = { + 'type': 'test' if self.test else 'caserun', + 'id': str(self.test.id) if self.test else str(self.caserun.id) + } + affected = { + 'type': 'page' if self.test else 'site', + 'id': str(self.test.page.id) if self.test else str(self.caserun.site.id), + 'str': self.test.page.page_url if self.test else self.caserun.site.site_url + } + + # defining detail components + intro = '' + main_issue = '' + recommendation = '' + + # building details, title, & labels + # for caserun failure + if self.caserun: + + # get first step that failed in caserun + failed_step = None + step_index = 0 + step_type = 'action' + for step in self.caserun.steps: + step_index += 1 + if step['action']['status'] == 'failed': + failed_step = step + step_type = 'action' + break + if step['assertion']['status'] == 'failed': + failed_step = step + step_type = 'assertion' + break + + # build title + title = f'Case Run "{self.caserun.title}" Failed' + + # build intro + intro = str( + f'### Case Run [{self.caserun.title}]({settings.CLIENT_URL_ROOT}/{trigger["type"]}/{trigger["id"]})' + + f' failed on **Step {step_index}**, `{failed_step[step_type]["type"]}`.\n\n\n' + + f' > Affected Site: [{affected["str"]}]({settings.CLIENT_URL_ROOT}/{affected["type"]}/{affected["id"]})\n\n\n' + ) + + # build main_issue + main_issue = str( + f'### Main Issue or Exception:\n' + + f' ```shell\n{failed_step[step_type]["exception"]}\n``` \n\n' + + f' [View Image]({failed_step[step_type]["image"]})\n\n' + ) + + # build recommendation + response = self.build_recommendation( + details = str(intro + main_issue) + ) + recommendation = str( + f'\n\n### Recommendations:\n' + + f'{response}' + ) + + # building details, title, & labels + # for test failure + if self.test: + + # grabbing component scores + # which were less than the test.threshold + ordered_scores = [] + for key in self.test.component_scores: + if self.test.component_scores[key] is not None: + if self.test.component_scores[key] < self.test.threshold: + ordered_scores.append({key: self.test.component_scores[key]}) + + # build components str + comp_str = str('| Component | Score |\n|:-----|-----:|') + for score in ordered_scores: + for key in score: + comp_str += f'\n| {key} | {round(score[key], 2)} |' + + # adjusting component names in table + comp_str = comp_str.replace( + 'vrt', + 'visual regression (vrt)' + ).replace( + 'html', + 'html regression (html)' + ) + + # build title + title = f'Test Failed at {round(self.test.score, 2)}%' + + # build intro + intro = str( + f'[Test]({settings.CLIENT_URL_ROOT}/{trigger["type"]}/{trigger["id"]}) failed for the page ' + + f'[{affected["str"]}]({settings.CLIENT_URL_ROOT}/{affected["type"]}/{affected["id"]}) ' + + f'based on the set threshold of **{round(self.test.threshold, 2)}%**.\n\n\n' + ) + + # build main_issue + main_issue = str( + f'### Failing Components:\n' + + f'{comp_str}' + ) + + # build recommendation + response = self.build_recommendation( + details = str(intro + main_issue) + ) + recommendation = str( + f'\n\n### Recommendations:\n' + + f'{response}' + ) + + # clean recommendation + recommendation = recommendation.replace('localhost', 'app.cursion.dev') + + # build details from components + details = intro + main_issue + recommendation + + # creating new Issue + issue = Issue.objects.create( + account = account, + title = title, + details = details, + labels = labels, + trigger = trigger, + affected = affected + ) + + # new Issue + return issue + + + + + def build_recommendation( + self, + details: str=None, + ) -> str: + """ + Using OpenAI's Chat GPT, composes a personalized + `recommendation` for the primary `Issue` being created. + + Expcets: { + 'details' : str, + } + + Returns -> str + """ + + # initializing + recommendation = '' + + # building recommendation + # for self.test + if self.test: + + # send the initial request + recommendation = self.gpt_client.chat.completions.create( + model="gpt-4o-mini", # old model -> gpt-3.5-turbo + messages=[ + { + "role": "user", + "content": f"Create a recommendation for developers \ + baseded on this generated issue: '\n\n{details}\n\n'. \ + The components are portions of a regression test of a website. \ + Format each recommendation with markdown. \ + Begin each recommendation with '- [ ]' to format as a task. \ + Omit the title or header in your response. \ + Remove any disclaimer or note section. \ + Remove any reference to 'Test Cases'. \ + Remove and reference to 'visual comparison tools'. \ + Max Length of Response: 170 words. \ + Tone: Instructive" + }, + ] + ).choices[0].message.content + + # building recommendation + # for self.caserun + if self.caserun: + + # send the initial request + recommendation = self.gpt_client.chat.completions.create( + model="gpt-4o-mini", # old model -> gpt-3.5-turbo + messages=[ + { + "role": "user", + "content": f"Create a recommendation for developers \ + baseded on this generated issue: '\n\n{details}\n\n'. \ + Format each recommendation with markdown. \ + Begin each recommendation with '- [ ]' to format as a task. \ + Omit the title or header in your response. \ + Omit any links in your response. \ + Remove any disclaimer or notes section. \ + Remove any reference to selenium documentation. \ + Remove any reference of 'alternative selector strategies'. \ + Max Length of Response: 170 words. \ + Tone: Instructive" + }, + ] + ).choices[0].message.content + + # return recommendation + return recommendation + + + + + diff --git a/app/api/utils/lighthouse.py b/app/api/utils/lighthouse.py index ad917045..c34cc458 100644 --- a/app/api/utils/lighthouse.py +++ b/app/api/utils/lighthouse.py @@ -1,155 +1,263 @@ -import subprocess, json +import subprocess, json, uuid, boto3, os, requests from ..models import Site, Scan +from .devices import get_device +from cursion import settings + + + class Lighthouse(): - """Initializes Google's Lighthouse CLI and runs an audit of the site""" + """ + Initializes Google's Lighthouse CLI and runs an audit of the site + + Use self.get_data() to init a run + """ - def __init__(self, site=None, configs=None): - self.site = site - self.configs = configs - self.sizes = configs['window_size'].split(',') + def __init__(self, scan=None): + self.scan = scan + self.site = self.scan.site + self.page = self.scan.page + self.configs = scan.configs + self.sizes = scan.configs['window_size'].split(',') + self.audits_url = '' + self.device_type = get_device( + scan.configs['browser'], + scan.configs['device'] + ) + + # initial scores object + self.scores = { + "seo": None, + "accessibility": None, + "performance": None, + "best_practices": None, + # "pwa": None, + "crux": None, + "average": None + } + + # initial audits object + self.audits = { + "seo": [], + "accessibility": [], + "performance": [], + "best_practices": [], + # "pwa": [], + "crux": [] + } - def init_audit(self): + def lighthouse_cli(self): + """ + Serves as the CLI method for collecting LH metrics. + Creates a sub process running lighthouse CLI + + Returns --> raw LH data (Dict) + """ + + # initiating subprocess for LH CLI proc = subprocess.Popen([ 'lighthouse', '--config-path=api/utils/custom-config.js', '--quiet', - self.site.site_url, + self.page.page_url, '--plugins=lighthouse-plugin-crux', '--chrome-flags="--no-sandbox --headless --disable-dev-shm-usage"', f'--screenEmulation.width={self.sizes[0]}', f'--screenEmulation.height={self.sizes[1]}', - f'--screenEmulation.{self.configs["device"]}', + f'--screenEmulation.{self.device_type}', '--output', 'json', - ], + ], stdout=subprocess.PIPE, user='app', ) - stdout_value = proc.communicate()[0] - return stdout_value + # retrieving data from process + stdout_value = proc.communicate()[0] + + # decode bytes into string + stdout_string = stdout_value.decode('iso-8859-1') - def get_data(self): - + # clean string of any errors try: - stdout_value = self.init_audit() - # decode bytes into string - stdout_string = stdout_value.decode('iso-8859-1') - - # clean string of any errors delm = '{\n "lighthouseVersion"' stdout_string = delm + stdout_string.split(delm)[1] + except Exception as e: + print(e) + + # encode back to bytes + stdout_value = stdout_string.encode('iso-8859-1') + + # converting stdout str into Dict + stdout_json = json.loads(stdout_value) + return stdout_json - # encode back to bytes - stdout_value = stdout_string.encode('iso-8859-1') - - if len(stdout_string) != 0: - if 'Runtime error encountered' in stdout_string: - error = {'error': 'lighthouse ran into a problem',} - return error - - stdout_json = json.loads(stdout_value) - - # initial audits object - audits = { - "seo": [], - "accessibility": [], - "performance": [], - "best-practices": [], - "lighthouse-plugin-crux": [], - "pwa": [] - } - - # iterating through categories to get relevant lh_audits and store them in their respective `audits = {}` obj - for cat in audits: - cat_audits = stdout_json["categories"].get(cat).get("auditRefs") - if cat_audits is not None: - for a in cat_audits: - if int(a["weight"]) > 0: - audit = stdout_json["audits"][a["id"]] - audits[cat].append(audit) - # changing audits names - audits['best_practices'] = audits.pop('best-practices') - audits['crux'] = audits.pop('lighthouse-plugin-crux') - - # get scores from each category - seo_score = round(stdout_json["categories"]["seo"]["score"] * 100) - accessibility_score = round(stdout_json["categories"]["accessibility"]["score"] * 100) - performance_score = round(stdout_json["categories"]["performance"]["score"] * 100) - best_practices_score = round(stdout_json["categories"]["best-practices"]["score"] * 100) - pwa_score = round(stdout_json["categories"]["pwa"]["score"] * 100) - - # attempting crux - try: - crux_score = round(stdout_json["categories"]["lighthouse-plugin-crux"]["score"] * 100) - except: - crux_score = 0 - - if crux_score == 0 : - crux_score = None - average_score = round(( - seo_score + accessibility_score + performance_score - + best_practices_score + pwa_score - )/ 5) - else: - average_score = round(( - seo_score + accessibility_score + performance_score - + best_practices_score + pwa_score + crux_score - )/ 6) - - scores = { - "seo": seo_score, - "accessibility": accessibility_score, - "performance": performance_score, - "best_practices": best_practices_score, - "pwa": pwa_score, - "crux": crux_score, - "average": average_score - } - - - data = { - "scores": scores, - "audits": audits, - "failed": False - } - - else: - raise RuntimeError + + + def lighthouse_api(self) -> dict: + """ + Serves as the API method for collecting LH metrics. + Sends API requests to + + Returns --> raw LH data (Dict) + """ + + # defaults + headers = { + "content-type": "application/json", + } + params = { + "url": self.page.page_url, + "strategy": self.configs["device"], + "key": settings.GOOGLE_CRUX_KEY + } + + # cats + cats = 'category=ACCESSIBILITY&category=BEST_PRACTICES&category=PERFORMANCE&category=PWA&category=SEO' + + # setting up initial request + res = requests.get( + url=f'{settings.LIGHTHOUSE_ROOT}?{cats}', + params=params, + headers=headers + ).json() + + # try to get just LH response + res = res.get('lighthouseResult') + + # return response + return res + + + + def process_data(self, stdout_json: dict) -> dict: + """ + Accepts JSON data from either CLI or API method + and parses into usable Cursion data. + + Expects the following: + stdout_json: or json from output - except Exception as e: - print(e) + Returns --> formatted LH data + """ + + # setup boto3 configurations + s3 = boto3.client( + 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), + aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), + region_name=str(settings.AWS_S3_REGION_NAME), + endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) + ) - scores = { - "seo": None, - "accessibility": None, - "performance": None, - "best_practices": None, - "pwa": None, - "crux": None, - "average": None - } - - audits = { - "seo": [], - "accessibility": [], - "performance": [], - "best_practices": [], - "pwa": [], - "crux": [] - } - - data = { - "scores": scores, - "audits": audits, - "failed": True - } + # changing audits & score names before iterations + self.scores['best-practices'] = self.scores.pop('best_practices') + self.audits['best-practices'] = self.audits.pop('best_practices') + self.audits['lighthouse-plugin-crux'] = self.audits.pop('crux') + + # iterating through categories to get relevant lh_audits + # and store them in their respective `audits = {}` obj + for cat in self.audits: + # skipping non-existent cat + if stdout_json["categories"].get(cat) is None: + continue + cat_audits = stdout_json["categories"].get(cat).get("auditRefs") + if cat_audits is not None: + for a in cat_audits: + if int(a["weight"]) > 0: + audit = stdout_json["audits"][a["id"]] + self.audits[cat].append(audit) + + # get scores from each category + score_queue = [] + for cat in self.scores: + # skipping non-existent cat + if stdout_json["categories"].get(cat) is None: + continue + # record score + self.scores[cat] = round(stdout_json["categories"][cat]["score"] * 100) + # add to queue + score_queue.append(self.scores[cat]) + + # changing audits & score names back to original + self.scores['best_practices'] = self.scores.pop('best-practices') + self.audits['best_practices'] = self.audits.pop('best-practices') + self.audits['crux'] = self.audits.pop('lighthouse-plugin-crux') + + # dynamically calculating average + average_score = round(sum(score_queue)/len(score_queue)) + self.scores['average'] = average_score + + + # save audits data as json file + file_id = uuid.uuid4() + with open(f'{file_id}.json', 'w') as fp: + json.dump(self.audits, fp) + # upload to s3 and return url + audit_file = os.path.join(settings.BASE_DIR, f'{file_id}.json') + remote_path = f'static/sites/{self.site.id}/{self.page.id}/{self.scan.id}/{file_id}.json' + root_path = settings.AWS_S3_URL_PATH + self.audits_url = f'{root_path}/{remote_path}' + + # upload to s3 + with open(audit_file, 'rb') as data: + s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), + remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "application/json"} + ) + # remove local copy + os.remove(audit_file) + + data = { + "scores": self.scores, + "audits": self.audits_url, + "failed": False + } + + # returning data + return data + + + + def get_data(self): + + scan_complete = False + failed = True + attempts = 0 + + # trying lighthouse scan untill success or 2 attempts + while not scan_complete and attempts < 2: + + try: + # CLI on first attempt + if attempts < 1: + raw_data = self.lighthouse_cli() + self.process_data(stdout_json=raw_data) + + # API after first attempt + if attempts >= 1: + raw_data = self.lighthouse_api() + self.process_data(stdout_json=raw_data) + + scan_complete = True + failed = False + + except Exception as e: + print(f'LIGHTHOUSE FAILED (attempt {attempts}) --> {e}') + scan_complete = False + failed = True + attempts += 1 + + data = { + "scores": self.scores, + "audits": self.audits_url if self.audits_url != '' else None, + "failed": failed + } + + # returning final data return data diff --git a/app/api/utils/reporter.py b/app/api/utils/reporter.py index 77dffb4a..8e06ae43 100644 --- a/app/api/utils/reporter.py +++ b/app/api/utils/reporter.py @@ -1,43 +1,65 @@ from ..models import * -import time, os, sys, json, boto3 -import PIL.Image as Img -from scanerr import settings -from datetime import datetime, timedelta +from cursion import settings from reportlab.lib.pagesizes import letter from reportlab.lib.units import inch from reportlab.lib.colors import HexColor from reportlab.pdfgen import canvas +import os, json, boto3, textwrap, requests + + + class Reporter(): + """ + Used for generating web vitals reports for + the associated `Page` & `Scan` - ''' - Used for generating web vitals reports for the passed `Site` obj + Expects: { + 'report': , + 'scan' : , + } + + Use self.generate_report() to create a new report - Expects -> { - "report": , + Returns: { + 'report' : object, + 'success': bool, + 'message': str } + """ - returns --> - - ''' - def __init__(self, report, scan=None): + + + def __init__(self, report: object, scan: object=None): + + # getting report, scan, & page self.report = report - self.site = self.report.site - if scan is None: - self.scan = Scan.objects.get(id=self.site.info['latest_scan']['id']) - else: - self.scan = scan + self.page = self.report.page + self.scan = scan - #building paths & canvas template - if os.path.exists(os.path.join(settings.BASE_DIR, f'temp/')): - self.local_path = os.path.join(settings.BASE_DIR, f'temp/{self.report.id}.pdf') + # retrieveing latest scan if none + if scan is None: + try: + self.scan = Scan.objects.filter( + page=self.page + ).exclude( + time_completed=None + ).order_by('-time_created')[0] + except Exception as e: + print(e) + self.scan = None + + # building paths & canvas template + if os.path.exists(os.path.join(settings.BASE_DIR, f'reports/')): + self.local_path = os.path.join(settings.BASE_DIR, f'reports/{self.report.id}.pdf') else: - os.makedirs(f'{settings.BASE_DIR}/temp') - self.local_path = os.path.join(settings.BASE_DIR, f'temp/{self.report.id}.pdf') + os.makedirs(f'{settings.BASE_DIR}/reports') + self.local_path = os.path.join(settings.BASE_DIR, f'reports/{self.report.id}.pdf') + # setting default colors self.page_index = 0 self.text_color = self.report.info['text_color'] self.highlight_color = self.report.info['highlight_color'] @@ -45,54 +67,103 @@ def __init__(self, report, scan=None): self.c = canvas.Canvas(self.local_path, letter) self.y = 9 + # define s3 instance + self.s3 = boto3.client('s3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), + aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), + region_name=str(settings.AWS_S3_REGION_NAME), + endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) + ) + - def setup_page(self): + + + def setup_page(self) -> None: # sets the defaults for a new page self.c.setFillColor(HexColor(self.background_color)) self.c.rect(0, 0, 8.5*inch, 11*inch, stroke=0, fill=1) + return None + + - def end_page(self): + def end_page(self) -> None: # adds page number and ends page self.c.setFont('Helvetica-Bold', 15) self.c.setFillColor(HexColor(self.text_color)) self.page_index += 1 self.c.drawString(7.7*inch, .3*inch, str(self.page_index)) self.c.showPage() + return None + + - def draw_page_title(self, title): + def draw_page_title(self, title: str) -> None: # adds a title to the given page self.c.setFont('Helvetica-Bold', 32) self.c.setFillColor(HexColor(self.text_color)) self.c.drawCentredString(4.25*inch, 10*inch, title) + return None + - def publish_report(self): - self.c.save() - remote_path = f'static/sites/{self.report.site.id}/{self.report.id}.pdf' - s3 = boto3.client('s3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), - aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), - region_name=str(settings.AWS_S3_REGION_NAME), - endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) - ) + def publish_report(self) -> None: + # saves report and uploads to s3 + self.c.save() + remote_path = f'static/sites/{self.report.page.site.id}/{self.report.page.id}/{self.report.id}.pdf' # uploading package to remote s3 with open(self.local_path, 'rb') as data: - s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), + self.s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), remote_path, ExtraArgs={ 'ACL': 'public-read', 'ContentType': 'application/pdf'} ) - + # building and saving report_url report_url = f'{settings.AWS_S3_URL_PATH}/{remote_path}#toolbar=0' - self.report.path = report_url self.report.save() os.remove(self.local_path) + return None + + + + + def draw_wrapped_line( + self, + text: str, + length: int, + x_pos: int, + y_pos: int, + y_offset: int + ) -> None: + """ + :param text: the raw text to wrap + :param length: the max number of characters per line + :param x_pos: starting x position + :param y_pos: starting y position + :param y_offset: the amount of space to leave between wrapped lines + """ + # Wraps the passed test at a certain char_length + if len(text) > length: + wraps = textwrap.wrap(text, length, break_long_words=True) + for x in range(len(wraps)): + self.c.drawString(x_pos*inch, y_pos*inch, wraps[x]) + y_pos -= y_offset + y_pos += y_offset # add back offset after last wrapped line + else: + self.c.drawString(x_pos*inch, y_pos*inch, text) + return None - def cover_page(self): + + def cover_page(self) -> None: + """ + Builds the cover page with a title + + Returns -> None + """ + # background and title self.setup_page() @@ -125,28 +196,40 @@ def cover_page(self): self.c.setFont('Helvetica-Bold', 45) self.c.setFillColor(HexColor(self.text_color)) self.c.drawString(.5*inch, 10*inch, 'Web Vitals for') - if len(self.site.site_url) <= 12: - self.c.drawString(.5*inch, 9*inch, self.site.site_url) - elif 12 < len(self.site.site_url): - extra_chars = len(self.site.site_url) - 12 - m = (3/5) - self.c.setFont('Helvetica-Bold', int(45 - (extra_chars * m))) - self.c.setFillColor(HexColor(self.text_color)) - self.c.drawString(.5*inch, 9*inch, self.site.site_url) + + # page url + font_size = max((30 * (26/len(self.page.page_url))), 16) + self.c.setFont('Helvetica-Bold', font_size) + self.draw_wrapped_line(text=self.page.page_url, length=65, x_pos=.5, y_pos=9, y_offset=.5) + # cover img cover_img = os.path.join(settings.BASE_DIR, "api/utils/report_assets/cover_img.png") self.c.drawImage(cover_img, 1*inch, 2*inch, 6.04*inch, 4.68*inch, mask='auto') - self.end_page() - + return None + - def get_score_data(self, score, is_binary=False): + def get_score_data(self, score: float, is_binary: bool=False) -> dict: + """ + Using the passed 'score', decide on + which grade and color to return. + + Expects: { + 'score' : float, + 'is_binary' : bool + } + + Returns -> dict + """ + + # calc score if binary score = float(score) if is_binary: score = score*100 + # defining score types score_types = { "a": { "grade": "A", @@ -175,6 +258,7 @@ def get_score_data(self, score, is_binary=False): } + # calculate grade if score >= 80: grade = score_types['a'] elif 80 > score >= 70: @@ -188,10 +272,16 @@ def get_score_data(self, score, is_binary=False): else: grade = score_types['f'] + # return return grade - def get_cat_string(self, cat): + + + def get_cat_string(self, cat: str) -> str: + """ + Returns the string coresponding to the passed 'cat' + """ if cat == 'fonts': string = 'Fonts' @@ -199,8 +289,8 @@ def get_cat_string(self, cat): string = 'Bad CSS' elif cat == 'jQuery': string = 'jQuery' - elif cat == 'requests': - string = 'Requests' + elif cat == 'images': + string = 'Images' elif cat == 'pageWeight': string = 'Page Weight' elif cat == 'serverConfig': @@ -228,18 +318,49 @@ def get_cat_string(self, cat): return string + + + + def get_audits(self, uri: str=None) -> dict: + """ + Downloads the JSON file from the passed uri + and return the data as a python dict + """ + if uri: + res = requests.get(uri) + audits = res.json() + return audits + else: + return [] + - def create_data(self, data_type=str): + + def create_data(self, data_type: str) -> None: + """ + Paints the data for the passed 'data_type', + either 'lighthouse' or 'yellowlab'. + + Expects: { + 'data_type': str + } + + Returns -> None + """ + + # add new page self.setup_page() + # decide on which data type if data_type == 'yellowlab': data = self.scan.yellowlab + data['audits'] = self.get_audits(data['audits']) page_title = 'Yellow Lab' avg_score = 'globalScore' - + if data_type == 'lighthouse': data = self.scan.lighthouse + data['audits'] = self.get_audits(data['audits']) page_title = 'Lighthouse' avg_score = 'average' @@ -298,7 +419,6 @@ def create_data(self, data_type=str): f'{data["scores"][avg_score]}/100' ) - # creating new page at limit --> 20 items if logs_count >= 20: self.end_page() @@ -310,8 +430,6 @@ def create_data(self, data_type=str): # creating space btw sections if c_count > 0 and logs_count != 0: begin_y = (self.y - .2) - - # creating individual grade cards grade_obj = self.get_score_data(data['scores'][cat]) @@ -341,7 +459,6 @@ def create_data(self, data_type=str): cat_string ) - p_count = 0 for policy in data['audits'][cat]: @@ -361,7 +478,6 @@ def create_data(self, data_type=str): policy_value = policy["displayValue"] binary = True - if len(policy_text) < 53: # creating log box self.c.setFont('Helvetica', 9) @@ -403,60 +519,63 @@ def create_data(self, data_type=str): (f'{policy_value}') ) - p_count += 1 logs_count += 1 self.y = (begin_y - (space * p_count)) c_count += 1 - - self.end_page() + return None + def generate_report(self) -> dict: + """ + Generates a new Report. + Returns -> data: { + 'report' : object, + 'success': bool, + 'message': str + } + """ + # setting defaults + message = 'Scan Page first' + success = False - - - - - - - - - - - - - - - - - - - def make_test_report(self): + # generating if scan is available + if self.scan: + + # add title + self.cover_page() - self.cover_page() - - if 'lighthouse' in self.report.type or 'full' in self.report.type: - self.create_data(data_type='lighthouse') - - if 'yellowlab' in self.report.type or 'full' in self.report.type: - self.create_data(data_type='yellowlab') + # build lighthouse data + if 'lighthouse' in self.report.type or 'full' in self.report.type: + self.create_data(data_type='lighthouse') - if 'crux' in self.report.type or 'full' in self.report.type: - self.setup_page() - self.draw_page_title('CRUX') - self.end_page() + # build yellowlab data + if 'yellowlab' in self.report.type or 'full' in self.report.type: + self.create_data(data_type='yellowlab') + + # save report + self.publish_report() + message = 'Report Generated' + success = True + + # formating response + data = { + 'report' : self.report, + 'success': success, + 'message': message + } - self.publish_report() - return self.report + # returning response + return data diff --git a/app/api/utils/scanner.py b/app/api/utils/scanner.py index 13424f82..2e66e0ca 100644 --- a/app/api/utils/scanner.py +++ b/app/api/utils/scanner.py @@ -1,104 +1,108 @@ -from .driver_s import driver_init as driver_s_init, quit_driver -from .driver_s import driver_wait -from .driver_p import get_data -from ..models import Site, Scan, Test -from django.forms.models import model_to_dict -from django.core.serializers.json import DjangoJSONEncoder +from .driver import ( + driver_init, quit_driver, + driver_wait , get_data +) +from ..models import * +from .alerter import Alerter +from .tester import Tester from .lighthouse import Lighthouse from .yellowlab import Yellowlab -from .image import Image +from .imager import Imager +from .updater import update_flowrun from datetime import datetime -import time, os, sys, json, asyncio +from cursion import settings +import os, asyncio, uuid, boto3, random, time + + + class Scanner(): + """ + Used to run and build all the + components of a new `Scan` + + Expects -> { + 'site' : object, + 'page' : object, + 'scan' : object, + 'type' : list + } + + Use self.build_scan() to create a new Scan + + Returns -> `Scan` object + """ + + + def __init__( self, - site=None, - scan=None, - configs=None, - type=['html', 'logs', 'vrt', 'lighthouse', 'yellowlab'] + site: object=None, + page: object=None, + scan: object=None, + type: list=['html', 'logs', 'vrt', 'lighthouse', 'yellowlab'] ): - if site == None and scan != None: - site = scan.site - - if configs is None: - configs = { - 'window_size': '1920,1080', - 'driver': 'selenium', - 'device': 'desktop', - 'mask_ids': None, - 'interval': 5, - 'min_wait_time': 10, - 'max_wait_time': 60, - 'timeout': 300, - 'disable_animations': False - } - self.site = site - - if configs['driver'] == 'selenium': - self.driver = driver_s_init(window_size=configs['window_size'], device=configs['device']) - - if scan is not None: - self.scan = scan - else: - self.scan = None - - self.configs = configs + self.page = page + self.scan = scan self.type = type + # getting page and site if None + if site == None and scan != None: + self.site = scan.site + if page == None and scan != None: + self.page = scan.page + + - def first_scan(self): + def build_scan(self) -> object: """ - Method to run a scan independently of an existing `scan` obj. + Method to run a scan independently of an existing `scan` obj. - returns -> `Scan` + Returns -> `Scan` """ + # setting defaults html = None logs = None images = None lh_data = None yl_data = None - - if self.scan is None: - self.scan = Scan.objects.create(site=self.site, type=self.type) - - if self.configs['driver'] == 'selenium': - self.driver.get(self.site.site_url) - if 'html' in self.scan.type or 'full' in self.scan.type: - html = self.driver.page_source - if 'logs' in self.scan.type or 'full' in self.scan.type: - logs = self.driver.get_log('browser') - if 'vrt' in self.scan.type or 'full' in self.scan.type: - images = Image().scan(site=self.site, driver=self.driver, configs=self.configs) - quit_driver(self.driver) - else: - driver_data = asyncio.run( - get_data( - url=self.site.site_url, - configs=self.configs - ) - ) - if 'html' in self.scan.type or 'full' in self.scan.type: - html = driver_data['html'] - if 'logs' in self.scan.type or 'full' in self.scan.type: - logs = driver_data['logs'] - if 'vrt' in self.scan.type or 'full' in self.scan.type: - images = asyncio.run(Image().scan_p(site=self.site, configs=self.configs)) + # running scan steps with selenium driver + driver = driver_init( + browser=self.scan.configs.get('browser', 'chrome'), + window_size=self.scan.configs['window_size'], + device=self.scan.configs['device'] + ) + driver.get(self.page.page_url) + driver_data = get_data( + driver=driver, + browser=self.scan.configs.get('browser', 'chrome'), + max_wait_time=self.scan.configs['max_wait_time'] + ) + if 'html' in self.scan.type or 'full' in self.scan.type: + html = driver_data['html'] + if 'logs' in self.scan.type or 'full' in self.scan.type: + logs = driver_data['logs'] + if 'vrt' in self.scan.type or 'full' in self.scan.type: + images = Imager(scan=self.scan).scan_vrt(driver=driver) if 'lighthouse' in self.scan.type or 'full' in self.scan.type: - lh_data = Lighthouse(site=self.site, configs=self.configs).get_data() + lh_data = Lighthouse(scan=self.scan).get_data() if 'yellowlab' in self.scan.type or 'full' in self.scan.type: - yl_data = Yellowlab(site=self.site, configs=self.configs).get_data() + yl_data = Yellowlab(scan=self.scan).get_data() + + # quiting selenium instance + quit_driver(driver) + # updating Scan object if html is not None: - self.scan.html = html + save_html(html, self.scan) if logs is not None: self.scan.logs = logs if images is not None: @@ -108,174 +112,206 @@ def first_scan(self): if yl_data is not None: self.scan.yellowlab = yl_data - self.scan.configs = self.configs + # saving scan data self.scan.time_completed = datetime.now() self.scan.save() - first_scan = self.scan - update_site_info(first_scan) + # update Scan.score + update_scan_score(self.scan) - return first_scan + # updating Site and Page objects + update_page_info(self.scan) + update_site_info(self.scan) + # return updated scan obj + return self.scan - def second_scan(self): - """ - Method to run a scan and attach existing `Scan` obj to it. +def update_scan_score(scan: object) -> object: + """ + Method to calculate the average health score and update + for the passed scan - returns -> `Scan` - """ - if not self.scan: - first_scan = Scan.objects.filter( - site=self.site, - time_completed__isnull=False - ).order_by('-time_created').first() - - else: - first_scan = self.scan + Expects: { + 'scan': object + } - # create second scan obj - second_scan = Scan.objects.create(site=self.site, type=self.type) + Returns -> `Scan` + """ + + # setting defaults + score = None + scores = [] - html = None - logs = None - images = None - lh_data = None - yl_data = None - - if self.configs['driver'] == 'selenium': - self.driver.get(self.site.site_url) - if 'html' in second_scan.type or 'full' in second_scan.type: - html = self.driver.page_source - if 'logs' in second_scan.type or 'full' in second_scan.type: - logs = self.driver.get_log('browser') - if 'vrt' in second_scan.type or 'full' in second_scan.type: - images = Image().scan(site=self.site, driver=self.driver, configs=self.configs) - quit_driver(self.driver) - else: - driver_data = asyncio.run( - get_data( - url=self.site.site_url, - configs=self.configs - ) - ) - if 'html' in second_scan.type or 'full' in second_scan.type: - html = driver_data['html'] - if 'logs' in second_scan.type or 'full' in second_scan.type: - logs = driver_data['logs'] - if 'vrt' in second_scan.type or 'full' in second_scan.type: - images = asyncio.run(Image().scan_p(site=self.site, configs=self.configs)) + # get latest scan scores + if scan.lighthouse['scores']['average'] is not None: + scores.append(scan.lighthouse['scores']['average']) + if scan.yellowlab['scores']['globalScore'] is not None: + scores.append(scan.yellowlab['scores']['globalScore']) + + # calc average score + if len(scores) > 0: + score = sum(scores)/len(scores) + + # save to scan + scan.score = score + scan.save() - if 'lighthouse' in second_scan.type or 'full' in second_scan.type: - lh_data = Lighthouse(site=self.site, configs=self.configs).get_data() - if 'yellowlab' in second_scan.type or 'full' in second_scan.type: - yl_data = Yellowlab(site=self.site, configs=self.configs).get_data() + # returning scan + return scan - if html is not None: - second_scan.html = html - if logs is not None: - second_scan.logs = logs - if images is not None: - second_scan.images = images - if lh_data is not None: - second_scan.lighthouse = lh_data - if yl_data is not None: - second_scan.yellowlab = yl_data - second_scan.configs = self.configs - second_scan.time_completed = datetime.now() - second_scan.paired_scan = first_scan - second_scan.save() - - first_scan.paried_scan = second_scan - first_scan.save() - update_site_info(second_scan) - - return second_scan +def update_site_info(scan: object) -> object: + """ + Method to update associated Site with the new Scan data + Expects: { + 'scan': object + } + Returns -> `Site` + """ + + # setting defaults + score = None + scores = [] + site = scan.site + pages = Page.objects.filter(site=site) + + # get latest scan of pages + scans = [] + for page in pages: + if Scan.objects.filter(page=page).exists(): + scan = Scan.objects.filter(page=page).order_by('-time_completed')[0] + if scan.score: + scores.append(scan.score) + + # calc average score + if len(scores) > 0: + score = sum(scores)/len(scores) + + # saving new info to site + site.info['latest_scan']['id'] = str(scan.id) + site.info['latest_scan']['time_created'] = str(scan.time_created) + site.info['latest_scan']['time_completed'] = str(scan.time_completed) + site.info['latest_scan']['score'] = score + site.save() + # returning site + return site -def update_site_info(scan): +def update_page_info(scan: object) -> object: """ - Method to update associated Site with the new Scan data + Method to update associated Page with the new Scan data - returns -> `Site` + Expects: { + 'scan': object + } + + Returns -> `Page` """ - - health = 'No Data' - badge = 'neutral' - d = 0 - score = 0 - site = scan.site - if scan.lighthouse['scores']['average'] is not None: - score += float(scan.lighthouse['scores']['average']) - d += 1 - if scan.yellowlab['scores']['globalScore'] is not None: - score += float(scan.yellowlab['scores']['globalScore']) - d += 1 - - if score != 0: - score = score / d - if score >= 75: - health = 'Good' - badge = 'success' - elif 75 > score >= 60: - health = 'Okay' - badge = 'warning' - elif 60 > score: - health = 'Poor' - badge = 'danger' - - else: - if scan.site.info['status']['score'] is not None: - score = float(site.info['status']['score']) - health = site.info['status']['health'] - badge = site.info['status']['badge'] - else: - score = None + # saving new info to page + scan.page.info['latest_scan']['id'] = str(scan.id) + scan.page.info['latest_scan']['time_created'] = str(scan.time_created) + scan.page.info['latest_scan']['time_completed'] = str(scan.time_completed) + scan.page.info['latest_scan']['score'] = scan.score + scan.page.info['lighthouse'] = scan.lighthouse.get('scores') + scan.page.info['yellowlab'] = scan.yellowlab.get('scores') + scan.page.save() - site.info['latest_scan']['id'] = str(scan.id) - site.info['latest_scan']['time_created'] = str(scan.time_created) - site.info['latest_scan']['time_completed'] = str(scan.time_completed) - site.info['lighthouse'] = scan.lighthouse.get('scores') - site.info['yellowlab'] = scan.yellowlab.get('scores') - site.info['status']['health'] = str(health) - site.info['status']['badge'] = str(badge) - site.info['status']['score'] = score + # returning page + return scan.page - site.save() - return site +def save_html(html: str, scan: object) -> object: + """ + Saves html page source as a '.txt' file and uploads + to s3. Then saves the remote uri to the `scan` obj. + Expects: { + html: str, + scan: object + } + Returns -> `Scan` + """ + # setup boto3 configuration + s3 = boto3.client( + 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), + aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), + region_name=str(settings.AWS_S3_REGION_NAME), + endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) + ) + + # save html data as text file + file_id = uuid.uuid4() + with open(f'{file_id}.txt', 'w') as fp: + fp.write(html) + + # upload to s3 and return url + html_file = os.path.join(settings.BASE_DIR, f'{file_id}.txt') + remote_path = f'static/sites/{scan.site.id}/{scan.page.id}/{scan.id}/{file_id}.txt' + root_path = settings.AWS_S3_URL_PATH + html_url = f'{root_path}/{remote_path}' + + # upload to s3 + with open(html_file, 'rb') as data: + s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), + remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "text/plain"} + ) + + # save to scan obj + scan.html = html_url + scan.save() + # remove local copy + os.remove(html_file) + + # return scan + return scan -def check_scan_completion(scan): +def check_scan_completion( + scan: object, + test_id: str=None, + alert_id: str=None, + flowrun_id: str=None, + node_index: str=None + ) -> object: """ - Method that checks if the scan has finished all - components. If so, method also updates scan and site - info. + Method that checks if the scan has finished all + components. If so, method also updates Scan, Site, + & Page info. If test_id is present, initiates a run_test() + + Expects: { + scan: object, + test_id: str, + alert_id: str + } - returns -> `Scan` + Returns -> `Scan` """ + # sleeping random for DB update + time.sleep(random.uniform(0.1, 2)) + + # setting defaults finished = True + # checking for each scan type completion if 'html' in scan.type or 'full' in scan.type: if scan.html == None or scan.html == '': finished = False @@ -299,150 +335,389 @@ def check_scan_completion(scan): # deciding if done if finished is True: time_completed = datetime.now() - update_site_info(scan) scan.time_completed = time_completed scan.save() - return scan + # update assoc site, page, & scan score + update_scan_score(scan) + update_page_info(scan) + update_site_info(scan) + # add scan to objects + objects = [{ + 'parent': str(scan.page.id), + 'id': str(test_id) if test_id else str(scan.id), + 'status': 'working' if test_id else 'passed' + }] + + # update flowrun + if flowrun_id and flowrun_id != 'None': + time.sleep(random.uniform(0.1, 5)) + update_flowrun(**{ + 'flowrun_id': str(flowrun_id), + 'node_index': node_index, + 'message': f'finished running all scan components for {scan.page.page_url} | scan_id: {str(scan.id)}', + 'objects': objects + }) + + # start Test if test_id present + if test_id is not None: + + # update flowrun + if flowrun_id and flowrun_id != 'None': + time.sleep(random.uniform(0.1, 5)) + update_flowrun(**{ + 'flowrun_id': str(flowrun_id), + 'node_index': node_index, + 'message': f'starting test comparison algorithm for {scan.page.page_url} | test_id: {str(test_id)}', + 'objects': objects + }) + + print('\n---------------\nScan Complete\nStarting Test...\n---------------\n') + test = Test.objects.get(id=test_id) + updated_test = Tester(test=test).run_test() + + # update flowrun + if flowrun_id and flowrun_id != 'None': + objects[-1]['status'] = updated_test.status + update_flowrun(**{ + 'flowrun_id': str(flowrun_id), + 'node_index': node_index, + 'message': ( + f'test for {scan.page.page_url} completed with status: '+ + f'{"❌ FAILED" if updated_test.status == 'failed' else "✅ PASSED"} | test_id: {str(test_id)}' + ), + 'objects': objects + }) + + if alert_id is not None and alert_id != 'None': + print('running alert from `cursion.check_scan_completion`') + obj_id = test_id if test_id else str(scan.id) + Alerter(alert_id=alert_id, object_id=obj_id).run_alert() + # returning scan + return scan -def _html_and_logs(scan_id): +def _html_and_logs( + scan_id: str=None, + test_id: str=None, + alert_id: str=None, + flowrun_id: str=None, + node_index: str=None + ) -> object: """ - Method to run the 'html' and 'logs' component of the scan - allowing for multi-threading. - - returns -> `Scan` + Method to run the 'html' and 'logs' component of the scan + allowing for multi-threading. + + Expects: { + scan_id : str, + test_id : str, + alert_id : str, + flowrun_id : str, + node_index : str + } + + Returns -> `Scan` """ - scan = Scan.objects.get(id=scan_id) - if scan.configs['driver'] == 'selenium': + # retrieve scan + scan = Scan.objects.get(id=scan_id) - driver = driver_s_init( + # setting defaults + message = None + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': f'starting html and logs component for {scan.page.page_url} | scan_id: {scan_id}', + }) + + try: + # get html and logs using selenium + # init driver & get data + driver = driver_init( + browser=scan.configs.get('browser', 'chrome'), window_size=scan.configs['window_size'], device=scan.configs['device'] ) - driver.get(scan.site.site_url) - if 'html' in scan.type or 'full' in scan.type: - html = driver.page_source - scan = Scan.objects.get(id=scan_id) - scan.html = html - scan.save() - if 'logs' in scan.type or 'full' in scan.type: - logs = driver.get_log('browser') - scan = Scan.objects.get(id=scan_id) - scan.logs = logs - scan.save() - quit_driver(driver) - - - if scan.configs['driver'] == 'puppeteer': - - driver_data = asyncio.run( - get_data( - url=scan.site.site_url, - configs=scan.configs - ) + driver.get(scan.page.page_url) + driver_data = get_data( + driver=driver, + browser=scan.configs.get('browser', 'chrome'), + max_wait_time=int(scan.configs['max_wait_time']), + min_wait_time=int(scan.configs['min_wait_time']), + interval=int(scan.configs['interval']) ) if 'html' in scan.type or 'full' in scan.type: html = driver_data['html'] scan = Scan.objects.get(id=scan_id) - scan.html = html - scan.save() + save_html(html, scan) if 'logs' in scan.type or 'full' in scan.type: logs = driver_data['logs'] scan = Scan.objects.get(id=scan_id) scan.logs = logs scan.save() + quit_driver(driver) + + # setting flowrun log + message = f'completed html and logs component for {scan.page.page_url} | scan_id: {scan_id}' + except Exception as e: + print(e) + + # setting flowrun log + message = f'html and logs component failed for {scan.page.page_url} | scan_id: {scan_id}' + + # try to quit selenium session + try: + quit_driver(driver) + except: + pass + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': message, + }) # checking if scan is done - scan = check_scan_completion(scan) + scan = check_scan_completion(scan, test_id, alert_id, flowrun_id, node_index) + # return udpated scan return scan - -def _vrt(scan_id): +def _vrt( + scan_id: str=None, + test_id: str=None, + alert_id: str=None, + flowrun_id: str=None, + node_index: str=None + ) -> object: """ - Method to run the visual regression (vrt) component of the scan - allowing for multi-threading. - - returns -> `Scan` + Method to run the visual regression (vrt) component of the scan + allowing for multi-threading. + + Expects: { + scan_id : str, + test_id : str, + alert_id : str, + flowrun_id : str, + node_index : str + } + + Returns -> `Scan` """ + + # retrieve scan scan = Scan.objects.get(id=scan_id) - if scan.configs['driver'] == 'selenium': - driver = driver_s_init(window_size=scan.configs['window_size'], device=scan.configs['device']) - images = Image().scan(site=scan.site, driver=driver, configs=scan.configs) + + # setting defaults + message = None + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': f'starting images (vrt) component for {scan.page.page_url} | scan_id: {scan_id}', + }) + + try: + # run Imager using selenium + driver = driver_init( + window_size=scan.configs.get('window_size', '1920,1080'), + device=scan.configs.get('device', 'desktop'), + browser=scan.configs.get('browser', 'chrome') + ) + images = Imager(scan=scan).scan_vrt(driver=driver) quit_driver(driver) + + # updating Scan object + scan = Scan.objects.get(id=scan_id) + scan.images = images + scan.save() + + # setting flowrun log + message = f'completed images (vrt) component for {scan.page.page_url} | scan_id: {scan_id}' + + except Exception as e: + print(e) - if scan.configs['driver'] == 'puppeteer': - images = asyncio.run(Image().scan_p(site=scan.site, configs=scan.configs)) + # setting flowrun log + message = f'html and logs component failed for {scan.page.page_url} | scan_id: {scan_id}' - # updating Scan object - scan = Scan.objects.get(id=scan_id) - scan.images = images - scan.save() + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': message + }) # checking if scan is done - scan = check_scan_completion(scan) + scan = check_scan_completion(scan, test_id, alert_id, flowrun_id, node_index) + # returning updated scan return scan - -def _lighthouse(scan_id): +def _lighthouse( + scan_id: str=None, + test_id: str=None, + alert_id: str=None, + flowrun_id: str=None, + node_index: str=None + ) -> object: """ - Method to run the lighthouse component of the scan - allowing for multi-threading. - - returns -> `Scan` + Method to run the lighthouse component of the scan + allowing for multi-threading. + + Expects: { + scan_id : str, + test_id : str, + alert_id : str, + flowrun_id : str, + node_index : str + } + + Returns -> `Scan` """ - scan = Scan.objects.get(id=scan_id) - # running lighthouse - lh_data = Lighthouse(site=scan.site, configs=scan.configs).get_data() - - # updating Scan object + # retrieve scan scan = Scan.objects.get(id=scan_id) - scan.lighthouse = lh_data - scan.save() - # checking if scan is done - scan = check_scan_completion(scan) + # setting defaults + message = None + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': f'starting lighthouse component for {scan.page.page_url} | scan_id: {scan_id}', + }) + + try: + # running lighthouse + lh_data = Lighthouse(scan=scan).get_data() + print(f'LIGHTHOUSE failure_status -> {lh_data.get('failed')}') + + # updating Scan object + scan = Scan.objects.get(id=scan_id) + scan.lighthouse = lh_data + scan.save() - return scan + # setting flowrun log + message = f'completed lighthouse component for {scan.page.page_url} | scan_id: {scan_id}' + + except Exception as e: + scan.lighthouse['failed'] = True + scan.save() + print(e) + + # setting flowrun log + message = f'lighthouse component failed for {scan.page.page_url} | scan_id: {scan_id}' + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': message + }) + # checking if scan is done + scan = check_scan_completion(scan, test_id, alert_id, flowrun_id, node_index) + # returning updated scan + return scan -def _yellowlab(scan_id): - """ - Method to run the yellowlab component of the scan - allowing for multi-threading. - returns -> `Scan` +def _yellowlab( + scan_id: str=None, + test_id: str=None, + alert_id: str=None, + flowrun_id: str=None, + node_index: str=None + ) -> object: + """ + Method to run the yellowlab component of the scan + allowing for multi-threading. + + Expects: { + scan_id : str, + test_id : str, + alert_id : str, + flowrun_id : str, + node_index : str + } + + Returns -> `Scan` """ + + # retrieve scan scan = Scan.objects.get(id=scan_id) - # running yellowlab - yl_data = Yellowlab(site=scan.site, configs=scan.configs).get_data() + # setting defaults + message = None + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': f'starting yellowlab component for {scan.page.page_url} | scan_id: {scan_id}', + }) - # updating Scan object - scan = Scan.objects.get(id=scan_id) - scan.yellowlab = yl_data - scan.save() + try: + # running yellowlab + yl_data = Yellowlab(scan=scan).get_data() + print(f'YELLOWLAB failure_status -> {yl_data.get('failed')}') + + # updating Scan object + scan = Scan.objects.get(id=scan_id) + scan.yellowlab = yl_data + scan.save() + + # setting flowrun log + message = f'completed yellowlab component for {scan.page.page_url} | scan_id: {scan_id}' + + except Exception as e: + scan.yellowlab['failed'] = True + scan.save() + print(e) + + # setting flowrun log + message = f'yellowlab component failed for {scan.page.page_url} | scan_id: {scan_id}' + + # update flowrun + if flowrun_id and flowrun_id != 'None': + update_flowrun(**{ + 'flowrun_id': flowrun_id, + 'node_index': node_index, + 'message': message + }) # checking if scan is done - scan = check_scan_completion(scan) + scan = check_scan_completion(scan, test_id, alert_id, flowrun_id, node_index) + # returning updated scan return scan + + + + diff --git a/app/api/utils/tester.py b/app/api/utils/tester.py index d5dd0d4a..e587e8ed 100644 --- a/app/api/utils/tester.py +++ b/app/api/utils/tester.py @@ -1,14 +1,37 @@ -from ..models import Site, Scan, Test -import time, os, sys, json, random, string, re -from difflib import SequenceMatcher, HtmlDiff, Differ +from ..models import * from datetime import datetime -from .image import Image +from .imager import Imager +from cursion import settings +from difflib import SequenceMatcher +from .issuer import Issuer +import os, json, random, \ +string, re, requests, uuid, boto3 + + + class Tester(): + """ + Used to run and build all the + components of a new `Test` + + Expects -> { + 'test' : object, + } + + Use self.run_test() to create a new Test + + Returns -> `Test` object + """ + + + - def __init__(self, test): + def __init__(self, test: object): + + # setting defaults self.test = test self.pre_scan_html = [] self.post_scan_html = [] @@ -17,26 +40,45 @@ def __init__(self, test): self.delta_html_post = [] self.delta_html_pre = [] + # setup boto3 configurations + self.s3 = boto3.client( + 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), + aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), + region_name=str(settings.AWS_S3_REGION_NAME), + endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) + ) + + + - def clean_html(self): - pre_scan_html = self.test.pre_scan.html.splitlines() - post_scan_html = self.test.post_scan.html.splitlines() + def clean_html(self) -> None: + # cleans both pre_ and post_ html + # and prepares them for comparison + # retrieveing data from remote s3 + pre_scan_html_raw = requests.get(self.test.pre_scan.html).text + post_scan_html_raw = requests.get(self.test.post_scan.html).text + pre_scan_html = pre_scan_html_raw.splitlines() + post_scan_html = post_scan_html_raw.splitlines() + + # setting watch lists white_list = ['csrfmiddlewaretoken', '',] tags = [ - '', '', '')) + # clean post_scan_html for line in post_scan_html: for item in white_list: if item in line: @@ -59,15 +102,21 @@ def clean_html(self): if sub not in tags: self.post_scan_html.append((sub+'>')) - return + return None + + - def clean_logs(self): + def clean_logs(self) -> None: + # cleans both pre_ and post_ logs + # and prepares them for comparison + + # setting defaults pre_scan_logs_json = self.test.pre_scan.logs post_scan_logs_json = self.test.post_scan.logs order = ("level", "source", "message") - + # cleaning pre_scan_logs for log in pre_scan_logs_json: new_log = {} for label in order: @@ -76,7 +125,7 @@ def clean_logs(self): new_log[label] = log.get(key) self.pre_scan_logs.append(json.dumps(new_log)) - + # cleaning post_scan_logs for log in post_scan_logs_json: new_log = {} for label in order: @@ -85,58 +134,80 @@ def clean_logs(self): new_log[label] = log.get(key) self.post_scan_logs.append(json.dumps(new_log)) - return + return None + + + + def compare_html(self) -> float: + # calculates the similarity of pre and post html + # using SequenceMatcher() - def compare_html(self): + # clean html first self.clean_html() - pre_scan = self.pre_scan_html - post_scan = self.post_scan_html + + # calculate score html_raw_score = SequenceMatcher( - None, pre_scan, post_scan + None, self.pre_scan_html, self.post_scan_html ).ratio() + # return score return html_raw_score - def compare_logs(self): + + def compare_logs(self) -> float: + # calculates the similarity of pre and post logs + # using SequenceMatcher() + + # clean logs first self.clean_logs() - pre_scan = list(self.pre_scan_logs) - post_scan = list(self.post_scan_logs) + + # calculate score logs_raw_score = SequenceMatcher( - None, pre_scan, post_scan + None, self.pre_scan_logs, self.post_scan_logs ).ratio() + # return score return logs_raw_score - def delta_html(self): + + + def delta_html(self) -> dict: + # Calculates the macro difference in pre_ & post_ html + # (i.e. difference in html nodes
). + # also generates the micro differences + # using self.post_proc_html() + + # calculate macro difference num_html_delta = len(self.pre_scan_html) - len(self.post_scan_html) num_html_ratio = len(self.pre_scan_html) / len(self.post_scan_html) if num_html_ratio > 1: num_html_ratio = len(self.post_scan_html) / len(self.pre_scan_html) + # building data for post_proc_html() for line in self.post_scan_html: if line not in self.pre_scan_html: self.delta_html_post.append(line) - for line in self.pre_scan_html: if line not in self.post_scan_html: self.delta_html_pre.append(line) - + # get pre_mciro_delta in html pre_micro_delta = self.post_proc_html( self.delta_html_pre, self.delta_html_post ) + # get post_mciro_delta in html post_micro_delta = self.post_proc_html( self.delta_html_post, self.delta_html_pre ) - + # formatting data data = { "num_html_delta": num_html_delta, "delta_html_post": self.delta_html_post, @@ -146,10 +217,17 @@ def delta_html(self): "post_micro_delta": post_micro_delta, } + # return updated data return data - def post_proc_html(self, primary_list, secondary_list): + + + def post_proc_html(self, primary_list: list, secondary_list: list) -> dict: + # generates a list of 8 char long chunks that are in + # the primary_list but not in the secondary_list + + # setting defaults delta_parsed = [] delta_parsed_diff = [] secondary_str = ''.join(str(i) for i in secondary_list) @@ -165,67 +243,89 @@ def post_proc_html(self, primary_list, secondary_list): if block != None and block != '' and block not in secondary_str: delta_parsed_diff.append(block) + # formatting data data = { "delta_parsed": delta_parsed, "delta_parsed_diff": delta_parsed_diff, } + # returning updated data return data - def html_micro_diff_score(self, post_delta_parsed_diff): + def html_micro_diff_score(self, post_delta_parsed_diff: list) -> float: + # Calculates a score by comparing + # post_delta_parsed_diff & pre_delta_parsed_diff + # building pre_delta_parsed_diff pre_delta_parsed_diff = [] for line in self.pre_scan_html: subStrings = re.findall('.{1,8}', line) for sub in subStrings: pre_delta_parsed_diff.append(sub) - + + # calculate score diff_length = len(pre_delta_parsed_diff) - len(post_delta_parsed_diff) diff_score = diff_length / len(pre_delta_parsed_diff) + # return score return diff_score - def post_proc_logs(self, log): + def post_proc_logs(self, log: str) -> dict: + # cleaning logs for comparions + # and convert to a dict + + # clean message log = json.loads(log) - log["message"].replace("\"", "\'") - letters = string.digits - timestamp = ''.join(random.choice(letters) for i in range(13)) + log["message"].replace("\"", "\'") + + # generate random timestamp + nums = string.digits + timestamp = ''.join(random.choice(nums) for i in range(13)) log['timestamp'] = timestamp + # return cleaned log return log - def delta_logs(self): + def delta_logs(self) -> dict: + # Calculates scores for log differences + # and builds lists to show diffferences + + # defaults + num_logs_ratio = 1 + delta_logs_post = [] + delta_logs_pre = [] + + # calc nums_log_delta (were there more in post_scan?) num_logs_delta = len(self.pre_scan_logs) - len(self.post_scan_logs) + # calculate ratio if len(self.post_scan_logs) > 0: num_logs_ratio = len(self.pre_scan_logs) / len(self.post_scan_logs) if num_logs_ratio > 1: - num_logs_ratio = 1 - else: - num_logs_ratio = 1 + num_logs_ratio = 1 - delta_logs_post = [] + # build list of not present post_scan_logs for log in self.post_scan_logs: if log not in self.pre_scan_logs: log = self.post_proc_logs(log) delta_logs_post.append(log) - - - delta_logs_pre = [] + + # build list of not present pre_scan_logs for log in self.pre_scan_logs: if log not in self.post_scan_logs: log = self.post_proc_logs(log) delta_logs_pre.append(log) + # formatting data data = { "num_logs_delta": num_logs_delta, "delta_logs_post": delta_logs_post, @@ -233,26 +333,32 @@ def delta_logs(self): "num_logs_ratio": num_logs_ratio, } + # returning data return data + def delta_lighthouse(self) -> dict: + # calculate the differences in LH + # scores between pre_ and post_ scans - def delta_lighthouse(self): try: + # get pre scores pre_seo = int(self.test.pre_scan.lighthouse["scores"]['seo']) pre_accessibility = int(self.test.pre_scan.lighthouse["scores"]['accessibility']) pre_performance = int(self.test.pre_scan.lighthouse["scores"]['performance']) pre_best_practices = int(self.test.pre_scan.lighthouse["scores"]['best_practices']) - pre_pwa = int(self.test.pre_scan.lighthouse["scores"]['pwa']) + # pre_pwa = int(self.test.pre_scan.lighthouse["scores"]['pwa']) if self.test.pre_scan.lighthouse["scores"]['pwa'] is not None else 0 + # get post scores post_seo = int(self.test.post_scan.lighthouse["scores"]['seo']) post_accessibility = int(self.test.post_scan.lighthouse["scores"]['accessibility']) post_performance = int(self.test.post_scan.lighthouse["scores"]['performance']) post_best_practices = int(self.test.post_scan.lighthouse["scores"]['best_practices']) - post_pwa = int(self.test.post_scan.lighthouse["scores"]['pwa']) + # post_pwa = int(self.test.post_scan.lighthouse["scores"]['pwa']) if self.test.pre_scan.lighthouse["scores"]['pwa'] is not None else 0 + # try to get pre and post crux scores try: pre_crux = int(self.test.pre_scan.lighthouse["scores"]['crux']) post_crux = int(self.test.post_scan.lighthouse["scores"]['crux']) @@ -262,34 +368,34 @@ def delta_lighthouse(self): post_crux = None crux_delta = 0 + # calculate individual deltas seo_delta = post_seo - pre_seo accessibility_delta = post_accessibility - pre_accessibility performance_delta = post_performance - pre_performance best_practices_delta = post_best_practices - pre_best_practices - pwa_delta = post_pwa - pre_pwa + # pwa_delta = post_pwa - pre_pwa + # calculate averages if post_crux is None: current_average = ( post_seo + post_accessibility + post_best_practices + - post_performance + post_pwa - )/5 - + post_performance # + post_pwa + )/4 old_average = ( pre_seo + pre_accessibility + pre_best_practices + - pre_performance + pre_pwa - )/5 - + pre_performance # + pre_pwa + )/4 else: current_average = ( post_seo + post_accessibility + post_best_practices + - post_performance + post_pwa + post_crux - )/6 - + post_performance + post_crux # + post_pwa + )/5 old_average = ( pre_seo + pre_accessibility + pre_best_practices + - pre_performance + pre_pwa + pre_crux - )/6 + pre_performance + pre_crux # + pre_pwa + )/5 + # calculate difference in averages average_delta = current_average - old_average except: @@ -297,36 +403,40 @@ def delta_lighthouse(self): accessibility_delta = None performance_delta = None best_practices_delta = None - pwa_delta = None + # pwa_delta = None crux_delta = None current_average = None average_delta = None + # formatting data data = { "scores": { "seo_delta": seo_delta, "accessibility_delta": accessibility_delta, "performance_delta": performance_delta, "best_practices_delta": best_practices_delta, - "pwa_delta": pwa_delta, + # "pwa_delta": pwa_delta, "crux_delta": crux_delta, "current_average": current_average, "average_delta": average_delta, } } + # returning data return data + def delta_yellowlab(self) -> dict: + # calculate the differences in YL + # scores between pre_ and post_ scans - - def delta_yellowlab(self): try: + # get pre scores pre_globalScore = int(self.test.pre_scan.yellowlab["scores"]['globalScore']) pre_pageWeight = int(self.test.pre_scan.yellowlab["scores"]['pageWeight']) - pre_requests = int(self.test.pre_scan.yellowlab["scores"]['requests']) + pre_images = int(self.test.pre_scan.yellowlab["scores"]['images']) pre_domComplexity = int(self.test.pre_scan.yellowlab["scores"]['domComplexity']) pre_javascriptComplexity = int(self.test.pre_scan.yellowlab["scores"]['javascriptComplexity']) pre_badJavascript = int(self.test.pre_scan.yellowlab["scores"]['badJavascript']) @@ -335,10 +445,11 @@ def delta_yellowlab(self): pre_badCSS = int(self.test.pre_scan.yellowlab["scores"]['badCSS']) pre_fonts = int(self.test.pre_scan.yellowlab["scores"]['fonts']) pre_serverConfig = int(self.test.pre_scan.yellowlab["scores"]['serverConfig']) - + + # get post scores post_globalScore = int(self.test.post_scan.yellowlab["scores"]['globalScore']) post_pageWeight = int(self.test.post_scan.yellowlab["scores"]['pageWeight']) - post_requests = int(self.test.post_scan.yellowlab["scores"]['requests']) + post_images = int(self.test.post_scan.yellowlab["scores"]['images']) post_domComplexity = int(self.test.post_scan.yellowlab["scores"]['domComplexity']) post_javascriptComplexity = int(self.test.post_scan.yellowlab["scores"]['javascriptComplexity']) post_badJavascript = int(self.test.post_scan.yellowlab["scores"]['badJavascript']) @@ -348,8 +459,9 @@ def delta_yellowlab(self): post_fonts = int(self.test.post_scan.yellowlab["scores"]['fonts']) post_serverConfig = int(self.test.post_scan.yellowlab["scores"]['serverConfig']) + # calculate individual deltas pageWeight_delta = post_pageWeight - pre_pageWeight - requests_delta = post_requests - pre_requests + images_delta = post_images - pre_images domComplexity_delta = post_domComplexity - pre_domComplexity javascriptComplexity_delta = post_javascriptComplexity - pre_javascriptComplexity badJavascript_delta = post_badJavascript - pre_badJavascript @@ -359,12 +471,13 @@ def delta_yellowlab(self): fonts_delta = post_fonts - pre_fonts serverConfig_delta = post_serverConfig - pre_serverConfig - average_delta = post_globalScore - pre_globalScore + # get current averag and calc average_delta current_average = post_globalScore - + average_delta = post_globalScore - pre_globalScore + except: pageWeight_delta = None - requests_delta = None + images_delta = None domComplexity_delta = None javascriptComplexity_delta = None badJavascript_delta = None @@ -376,10 +489,11 @@ def delta_yellowlab(self): average_delta = None current_average = None, + # formatting response data = { "scores": { "pageWeight_delta": pageWeight_delta, - "requests_delta": requests_delta, + "images_delta": images_delta, "domComplexity_delta": domComplexity_delta, "javascriptComplexity_delta": javascriptComplexity_delta, "badJavascript_delta": badJavascript_delta, @@ -393,27 +507,225 @@ def delta_yellowlab(self): } } + # returning data return data - def update_site_info(self, test): + + def get_lh_audits_deltas(self, scores: dict) -> str: + # finds and records the changes in LH audit data + # then saves as .json file in s3 and returns + + # defaults + audits = { + "seo":[], + "accessibility": [], + "performance": [], + "pwa": [], + "best_practices": [], + "crux": [] + } + + # get pre & post audits + pre_scan_audits = requests.get(self.test.pre_scan.lighthouse['audits']).json() + post_scan_audits = requests.get(self.test.post_scan.lighthouse['audits']).json() + + # deciding which categories to + # compare based on score + cats = [] + for key in scores: + # checking for a delta score + if '_delta' in key and 'average' not in key: + # check if delta not Zero + if scores[key] is not None: + if float(scores[key]) != 0: + cats.append(str(key).split('_delta')[0]) + + # compare each audit in each of the + # selected categories + for cat in cats: + for audit in post_scan_audits[cat]: + found = False + for aud in pre_scan_audits[cat]: + if audit == aud: + found = True + break + + # record post_ audit if not + # found in pre_ + if not found: + audits[cat].append(audit) + + # save data at .json in s3 + lh_audit_file_uri = self.save_data_to_s3(_data=audits) + + # return uri + return lh_audit_file_uri + + + + + def get_yl_audits_deltas(self, scores: dict) -> str: + # finds and records the changes in YL audit data + # then saves as .json file in s3 and returns + + # defaults + audits = { + "pageWeight":[], + "images": [], + "domComplexity": [], + "javascriptComplexity": [], + "badJavascript": [], + "jQuery": [], + "cssComplexity": [], + "badCSS": [], + "fonts": [], + "serverConfig": [], + } + + # get pre & post audits + pre_scan_audits = requests.get(self.test.pre_scan.yellowlab['audits']).json() + post_scan_audits = requests.get(self.test.post_scan.yellowlab['audits']).json() + + # deciding which categories to + # compare based on score + cats = [] + for key in scores: + # checking for a delta score + if '_delta' in key and 'average' not in key: + # check if delta not Zero + if scores[key] is not None: + if float(scores[key]) != 0: + cats.append(str(key).split('_delta')[0]) + + # compare each audit in each of the + # selected categories + for cat in cats: + for audit in post_scan_audits[cat]: + found = False + for aud in pre_scan_audits[cat]: + if audit == aud: + found = True + break + + # record post_ audit if not + # found in pre_ + if not found: + audits[cat].append(audit) + + # save data at .json in s3 + yl_audit_file_uri = self.save_data_to_s3(_data=audits) + + # return uri + return yl_audit_file_uri + + + + + def update_site_info(self, test: object) -> object: + # updates associated Site with + # new Test data + + # get associated site site = test.site - site.info['latest_test']['id'] = str(test.id) - site.info['latest_test']['time_created'] = str(test.time_created) - site.info['latest_test']['time_completed'] = str(test.time_completed) - site.info['latest_test']['score'] = (round(test.score * 100) / 100) - site.save() + # get pages + pages = Page.objects.filter(site=site) + + # get latest tests of pages + tests = [] + for page in pages: + if Test.objects.filter(page=page).exists(): + _test = Test.objects.filter(page=page).exclude( + time_completed=None + ).order_by('-time_completed') + if len(_test) > 0: + if _test[0].score is not None: + tests.append(_test[0].score) + + if len(tests) > 0: + + # calc site average of latest + site_avg_test_score = round((sum(tests)/len(tests)) * 100) / 100 + print(f'updating site with new test score -> {site_avg_test_score}') + + # update site info + site.info['latest_test']['id'] = str(test.id) + site.info['latest_test']['time_created'] = str(test.time_created) + site.info['latest_test']['time_completed'] = str(test.time_completed) + site.info['latest_test']['score'] = site_avg_test_score + site.info['latest_test']['status'] = test.status + site.save() + + # returning updated site return site + + + + + def update_page_info(self, test: object) -> object: + # updates associated Page with + # new Test data + + # get page + page = test.page + + # update page info + page.info['latest_test']['id'] = str(test.id) + page.info['latest_test']['time_created'] = str(test.time_created) + page.info['latest_test']['time_completed'] = str(test.time_completed) + page.info['latest_test']['score'] = (round(test.score * 100) / 100) + page.info['latest_test']['status'] = test.status + page.save() + + # return updated page + return page + + + + + def save_data_to_s3(self, _data: dict) -> str: + # Saves passed data as an s3 object and + # returns the remote uri as a str + + # save _data s3 json file + file_id = uuid.uuid4() + with open(f'{file_id}.json', 'w') as fp: + json.dump(_data, fp) + # upload to s3 and return url + data_file = os.path.join(settings.BASE_DIR, f'{file_id}.json') + remote_path = f'static/sites/{self.test.site.id}/{self.test.page.id}/{self.test.id}/{file_id}.json' + root_path = settings.AWS_S3_URL_PATH + data_file_uri = f"{root_path}/{remote_path}" + + # upload to s3 + with open(data_file, 'rb') as data: + self.s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), + remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "application/json"} + ) + # remove local copy + os.remove(data_file) + # return uri + return data_file_uri - def run_test(self, index=None): + def run_test(self, index: int=None) -> object: + """ + Runs all the test components specified in the `Test` + and returns the updated `Test` + + Expects: { + 'index': int + } + + Returns -> `Test` object + """ # update test obj with scan configs self.test.pre_scan_configs = self.test.pre_scan.configs @@ -442,115 +754,148 @@ def run_test(self, index=None): # default data html_delta_context = None + html_delta_uri = None logs_delta_context = None lighthouse_data = None yellowlab_data = None images_data = None - - + # testing html if 'html' in self.test.type or 'full' in self.test.type: - # scores - html_score = self.compare_html() - delta_html_data = self.delta_html() - num_html_ratio = delta_html_data['num_html_ratio'] - micro_diff_score = self.html_micro_diff_score( + try: + # scores + html_score = self.compare_html() + delta_html_data = self.delta_html() + num_html_ratio = delta_html_data['num_html_ratio'] + micro_diff_score = self.html_micro_diff_score( delta_html_data['post_micro_delta']['delta_parsed_diff'] ) - - # weights - html_score_w = 1 - num_html_w = 1 - micro_diff_w = 2 - - # data - html_delta_context = { - "pre_html_delta": delta_html_data['delta_html_pre'], - "post_html_delta": delta_html_data['delta_html_post'], - "pre_micro_delta": delta_html_data['pre_micro_delta'], - "post_micro_delta": delta_html_data['post_micro_delta'], - } - + + # weights + html_score_w = 1 + num_html_w = 1 + micro_diff_w = 2 + + # data + html_delta_context = { + "pre_html_delta": delta_html_data['delta_html_pre'], + "post_html_delta": delta_html_data['delta_html_post'], + "pre_micro_delta": delta_html_data['pre_micro_delta'], + "post_micro_delta": delta_html_data['post_micro_delta'], + } + + # save and get s3 object uri + html_delta_uri = self.save_data_to_s3(_data=html_delta_context) + print(f'html_delta => {html_delta_uri}') + except Exception as e: + print(e) + micro_diff_w = 0 + num_html_w = 0 + micro_diff_w = 0 + + - + # testing logs if 'logs' in self.test.type or 'full' in self.test.type: - # scores - logs_score = self.compare_logs() - delta_logs_data = self.delta_logs() - num_logs_ratio = delta_logs_data['num_logs_ratio'] - - # weights - logs_score_w = .5 - num_logs_w = 2 - - # data - logs_delta_context = { - "pre_logs_delta": delta_logs_data['delta_logs_pre'], - "post_logs_delta": delta_logs_data['delta_logs_post'], - } - - + try: + # scores + logs_score = self.compare_logs() + delta_logs_data = self.delta_logs() + num_logs_ratio = delta_logs_data['num_logs_ratio'] + + # weights + logs_score_w = .5 + num_logs_w = 2 + + # combined score + combined_logs_score = ((logs_score*logs_score_w) + (num_logs_ratio*num_logs_w))/2.5 + + # data + logs_delta_context = { + "pre_logs_delta": delta_logs_data['delta_logs_pre'], + "post_logs_delta": delta_logs_data['delta_logs_post'], + "combined_logs_score": combined_logs_score + } + except Exception as e: + logs_score_w = 0 + print(e) + + # testing LH if 'lighthouse' in self.test.type or 'full' in self.test.type: - # scores & data - lighthouse_data = self.delta_lighthouse() - lighthouse_avg = lighthouse_data['scores']['average_delta'] - if lighthouse_avg != None and lighthouse_avg > -100: - lighthouse_score = (100 + lighthouse_avg)/100 - if lighthouse_avg != None and lighthouse_avg <= -100: - lighthouse_score = 0 - - # weights - if lighthouse_score == None: + try: + # scores & data + lighthouse_data = self.delta_lighthouse() + lh_audits_uri = self.get_lh_audits_deltas(scores=lighthouse_data['scores']) + lighthouse_data['audits'] = lh_audits_uri + lighthouse_avg = lighthouse_data['scores']['average_delta'] + if lighthouse_avg != None and lighthouse_avg > -100: + lighthouse_score = (100 + lighthouse_avg)/100 + if lighthouse_avg != None and lighthouse_avg <= -100: + lighthouse_score = 0 + + # weights + if lighthouse_score == None: + delta_lh_w = 0 + elif lighthouse_score > 1: + delta_lh_w = 1 + lighthouse_score = 1 + else: + delta_lh_w = 1 + except Exception as e: delta_lh_w = 0 - elif lighthouse_score > 1: - delta_lh_w = 1 - lighthouse_score = 1 - else: - delta_lh_w = 1 - - - + print(e) + + # testing YL if 'yellowlab' in self.test.type or 'full' in self.test.type: - # scores & data - yellowlab_data = self.delta_yellowlab() - yellowlab_avg = yellowlab_data['scores']['average_delta'] - if yellowlab_avg != None and yellowlab_avg > -100: - yellowlab_score = (100 + yellowlab_avg)/100 - if yellowlab_avg != None and yellowlab_avg <= -100: - yellowlab_score = 0 - - # weights - if yellowlab_score == None: + try: + # scores & data + yellowlab_data = self.delta_yellowlab() + yl_audits_uri = self.get_yl_audits_deltas(scores=yellowlab_data['scores']) + yellowlab_data['audits'] = yl_audits_uri + yellowlab_avg = yellowlab_data['scores']['average_delta'] + if yellowlab_avg != None and yellowlab_avg > -100: + yellowlab_score = (100 + yellowlab_avg)/100 + if yellowlab_avg != None and yellowlab_avg <= -100: + yellowlab_score = 0 + + # weights + if yellowlab_score == None: + delta_yl_w = 0 + elif yellowlab_score > 1: + delta_yl_w = 1 + yellowlab_score = 1 + else: + delta_yl_w = 1 + except Exception as e: delta_yl_w = 0 - elif yellowlab_score > 1: - delta_yl_w = 1 - yellowlab_score = 1 - else: - delta_yl_w = 1 - - - + print(e) + + # testing images if 'vrt' in self.test.type or 'full' in self.test.type: - # scores & data - images_data = Image().test(test=self.test, index=index) - if images_data['average_score'] != None: - images_score = images_data['average_score'] / 100 - - # weights - images_w = 4 + try: + # scores & data + images_data = Imager().test_vrt(test=self.test, index=index) + if images_data['average_score'] != None: + images_score = images_data['average_score'] / 100 + + # weights + images_w = 4 + except Exception as e: + images_w = 0 + print(e) - + # calculating total weight total_w = ( html_score_w + logs_score_w + num_html_w + num_logs_w + delta_lh_w + micro_diff_w + images_w + delta_yl_w ) - + # calculating final weighted average score score = (( (html_score * html_score_w) + (logs_score * logs_score_w) + @@ -562,7 +907,6 @@ def run_test(self, index=None): (images_score * images_w) ) / total_w) * 100 - print( "Formula was --> ((" + str(html_score*html_score_w) + " + " + str(logs_score*logs_score_w) + " + " + str(num_logs_ratio*num_logs_w) + " + " @@ -571,27 +915,33 @@ def run_test(self, index=None): " + " + str(yellowlab_score*delta_yl_w) + ") / " + str(total_w) + ") * 100 ===> " + str(score) ) - + # updating test data self.test.time_completed = datetime.now() - self.test.html_delta = html_delta_context + self.test.html_delta = html_delta_uri self.test.logs_delta = logs_delta_context self.test.lighthouse_delta = lighthouse_data self.test.yellowlab_delta = yellowlab_data self.test.images_delta = images_data self.test.score = score - self.test.component_scores['html'] = (micro_diff_score * 100) - self.test.component_scores['logs'] = (num_logs_ratio * 100) - self.test.component_scores['lighthouse'] = (lighthouse_score * 100) - self.test.component_scores['yellowlab'] = (yellowlab_score * 100) - self.test.component_scores['vrt'] = (images_score * 100) - - + self.test.status = 'passed' if score >= self.test.threshold else 'failed' + self.test.component_scores['html'] = (micro_diff_score * 100) if micro_diff_w != 0 else None + self.test.component_scores['logs'] = (logs_delta_context['combined_logs_score'] * 100) if num_logs_w != 0 else None + self.test.component_scores['lighthouse'] = (lighthouse_score * 100) if delta_lh_w != 0 else None + self.test.component_scores['yellowlab'] = (yellowlab_score * 100) if delta_yl_w != 0 else None + self.test.component_scores['vrt'] = (images_score * 100) if images_w != 0 else None self.test.save() + # updating associated page and site + self.update_page_info(self.test) self.update_site_info(self.test) - return self.test + # create issue if failed + if self.test.status == 'failed' and self.test.post_scan_configs.get('create_issue'): + print('generating new Issue...') + Issuer(test=self.test).build_issue() + # returning updated test + return self.test diff --git a/app/api/utils/updater.py b/app/api/utils/updater.py new file mode 100644 index 00000000..ff954a94 --- /dev/null +++ b/app/api/utils/updater.py @@ -0,0 +1,168 @@ +from ..models import * +from datetime import datetime, timezone + + + + + + +def update_flowrun(*args, **kwargs) -> object: + """ + Updates the `FlowRun`, matching the 'flowrun_id', + with the **kwargs data + + Expects: { + 'kwargs' : { + 'flowrun_id' : str + 'node_index' : int or str, + 'messsage' : str, + 'node_status' : str, + 'objects' : list of dicts + } + } + + Returns: `FlowRun` obj + """ + + # get passed kwargs + flowrun_id = kwargs.get('flowrun_id') + node_index = kwargs.get('node_index') + node_status = kwargs.get('node_status') + message = kwargs.get('message') + objects = kwargs.get('objects') + + # get flowrun + flowrun = FlowRun.objects.get(id=flowrun_id) + + # set timestamp + timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f') + + + # find flowrun.edge by target + def get_edge_by_target(target: str=None) -> dict: + # defaults + edge = None + index = 0 + # find target + for e in flowrun.edges: + if e['target'] == target: + edge = e + break + index+=1 + # return data + return { + 'index': index, + 'edge': edge + } + + + # update object_list + def add_or_update_objects(object_list, objects): + i = 0 + # find obj + for obj in objects: + exists = False + j = 0 + for o in object_list: + if obj['parent'] == o['parent']: + exists = True + # update + object_list[j] = obj + break + j+=1 + # add + if not exists: + object_list.append(obj) + i+=1 + return object_list + + + # check if all objects are complete + def objects_are_complete(object_list): + if len(object_list) == 0: + return True + for obj in object_list: + if obj['status'] == 'working': + return False + return True + + + # get collective status of + def get_step_status(object_list): + statuses = [obj['status'] for obj in object_list] + if len(object_list) == 0: + return 'passed' + if 'working' in statuses: + return 'working' + if 'failed' in statuses and 'working' not in statuses: + return 'failed' + return 'passed' + + + # get datetime from str + def get_timestamp(timestamp): + # format for timestamp + f = '%Y-%m-%d %H:%M:%S.%f' + # clean timestamp str + clean_str = timestamp.replace('T', ' ').replace('Z', '') + # format date str as datetime obj + return datetime.strptime(clean_str, f) + + + # update flowrun logs, nodes, & edges + nodes = flowrun.nodes + edges = flowrun.edges + logs = flowrun.logs + + + if node_index is not None: + # get node object_list + object_list = nodes[int(node_index)]['data'].get('objects', []) + + # update object_list if objects + if objects: + object_list = add_or_update_objects(object_list, objects) + nodes[int(node_index)]['data']['objects'] = object_list + + # if node_status is provided + if node_status: + nodes[int(node_index)]['data']['status'] = node_status + if node_status != 'working': + nodes[int(node_index)]['data']['time_completed'] = timestamp + + # decide on node status if 'node_status' not provided + if not node_status: + complete = objects_are_complete(object_list) + nodes[int(node_index)]['data']['status'] = get_step_status(object_list) if complete else 'working' + nodes[int(node_index)]['data']['time_completed'] = timestamp if complete else None + + # update current edge if not at flowrun start + if int(node_index) != 0: + edge_index = get_edge_by_target(target=nodes[int(node_index)]['id'])['index'] + edges[edge_index]['animated'] = True if nodes[int(node_index)]['data']['status'] == 'working' else False + edges[edge_index]['style'] = {'stroke': "#60a5fa"} if nodes[int(node_index)]['data']['status'] == 'working' else None + + + if message: + # update current logs + logs.append({ + 'timestamp': timestamp, + 'message': message, + 'step': nodes[int(node_index)]['id'] if node_index else logs[-1]['step'] + }) + + # sort new logs + logs = sorted(logs, key=lambda l: (int(l['step']))) + + + # save updates + flowrun.nodes = nodes + flowrun.edges = edges + flowrun.logs = logs + flowrun.save() + + # signals.py should pickup this `update()` event and + # execute the run_next() instance of flowr.py + + # return updated flowrun + return flowrun \ No newline at end of file diff --git a/app/api/utils/verify.py b/app/api/utils/verify.py index eb119c4f..af11358a 100644 --- a/app/api/utils/verify.py +++ b/app/api/utils/verify.py @@ -1,30 +1,39 @@ -import os, requests, json +import os, requests, json, signal + + + + + def verify(): - username = os.environ.get('ADMIN_USER') - email = os.environ.get('ADMIN_EMAIL') - password = os.environ.get('ADMIN_PASS') - cred = 'l13g4c15ly34861o341uy3chgtlyv183njoq9u3f654792' - url = 'https://scanerr.io/verify' - - - headers = { - "Content-Type": "application/json", - "Authorization" : cred - } - data = { - "username": username, - "email": email, - "password": password, - } - - res = requests.get( - url=url, - headers=headers, - params=data - ).json() - - if res['verified']: - return - else: - os.abort() \ No newline at end of file + + if os.environ.get('MODE') == 'selfhost': + username = os.environ.get('ADMIN_USER') + email = os.environ.get('ADMIN_EMAIL') + license_key = os.environ.get('LICENSE_KEY') + api_root = os.environ.get('API_URL_ROOT') + client_root = os.environ.get('CLIENT_URL_ROOT') + url = f'{os.environ.get('LANDING_URL_ROOT')}/ops/verify' + + headers = { + "Content-Type": "application/json", + } + + data = { + "username": username, + "email": email, + "license_key": license_key, + "api_root": api_root, + "client_root": client_root + } + + res = requests.get( + url=url, + headers=headers, + params=data + ).json() + + if res.get('verified'): + return + else: + os.kill(os.getpid(), signal.SIGTERM) \ No newline at end of file diff --git a/app/api/utils/wordpress.py b/app/api/utils/wordpress.py index f94ea864..a5de7704 100644 --- a/app/api/utils/wordpress.py +++ b/app/api/utils/wordpress.py @@ -1,4 +1,4 @@ -from .driver_s import driver_init, driver_wait +from .driver import driver_init, driver_wait from selenium import webdriver from selenium.webdriver.support.ui import Select from selenium.webdriver.common.keys import Keys @@ -11,7 +11,6 @@ - class Wordpress(): @@ -518,7 +517,7 @@ def run_migration(self): #

Your migration is complete!

# get full page div if new_progress >= 100 or done_text in self.driver.page_source: - self.process.successful = True + self.process.success = True self.process.time_completed = datetime.now() done = True diff --git a/app/api/utils/wordpress_p.py b/app/api/utils/wordpress_p.py deleted file mode 100644 index 889a00ff..00000000 --- a/app/api/utils/wordpress_p.py +++ /dev/null @@ -1,571 +0,0 @@ -from .driver_p import driver_init -import time, asyncio, uuid -from ..models import * -from datetime import datetime -from asgiref.sync import sync_to_async - - - - - - - -class Wordpress(): - - - def __init__( - self, - login_url, - admin_url, - username, - password, - email_address, - destination_url, - sftp_address, - dbname, - sftp_username, - sftp_password, - wait_time, - process_id - ): - # set all global vars - self.login_url = login_url - self.username = username - self.password = password - self.email_address = email_address - self.destination_url = destination_url - self.sftp_address = sftp_address - self.dbname = dbname - self.sftp_username = sftp_username - self.sftp_password = sftp_password - self.process = Process.objects.get(id=process_id) - self.native_lang = 'en' - - if not admin_url.endswith('/'): - admin_url = admin_url + '/' - self.admin_url = admin_url - - if wait_time is None: - self.wait_time = 30 - else: - self.wait_time = wait_time - - self.navWaitOpt = { - 'timeout': self.wait_time * 1000, - 'waitUntil': 'domcontentloaded' - } - - - async def login(self): - - ''' - Tries to log into a WP site with given credentials. - - returns --> True / False - - ''' - - print('begining login method for ' + self.login_url) - - - self.driver = await driver_init(wait_time=self.wait_time) - - # init page obj - self.page = await self.driver.newPage() - page_options = { - 'waitUntil': 'networkidle0', - 'timeout': self.wait_time * 1000 - } - - try: - await self.page.goto(self.login_url, page_options) - try: - await self.page.xpath('//*[@id="user_login"]') - print('found login form') - except: - try: - jetpack = await self.page.xpath('//*[@id="jetpack-sso-wrap"]/a[1]') - await jetpack[0].click() - await self.page.xpath('//*[@id="user_login"]') - print('found login form') - except: - try: - login_link = await self.page.xpath("//a[contains(., 'Login with username and password')]") - await login_link[0].click() - await self.page.xpath('//*[@id="user_login"]') - print('found login form') - except: - print('unable to locate login form at this path') - await self.driver.close() - return False - - - except: - print('unable to locate login form at this path') - await self.driver.close() - return False - - user_name_elem = await self.page.xpath('//*[@id="user_login"]') - await user_name_elem[0].click(clickCount=3) - await self.page.keyboard.type(self.username) - time.sleep(1) - passworword_elem = await self.page.xpath('//*[@id="user_pass"]') - await passworword_elem[0].click(clickCount=3) - await self.page.keyboard.type(self.password) - time.sleep(1) - await self.page.keyboard.press('Enter') - await self.page.waitForNavigation(self.navWaitOpt) - - - try: - try: - verify_email = await self.page.xpath('//*[@id="correct-admin-email"]') - print('need to verify email') - await verify_email[0].click() - print('clicked verify') - except: - pass - - print('done with login attempt') - - try: - await self.page.xpath('//*[@id="login_error"]') - print('found login error') - await self.page.reload() - - print('trying login again') - user_name_elem = await self.page.xpath('//*[@id="user_login"]') - await user_name_elem[0].click(clickCount=3) - await self.page.keyboard.type(self.username) - time.sleep(1) - passworword_elem = await self.page.xpath('//*[@id="user_pass"]') - await passworword_elem[0].click(clickCount=3) - await self.page.keyboard.type(self.password) - time.sleep(1) - await self.page.keyboard.press('Enter') - await self.page.waitForNavigation(self.navWaitOpt) - - - try: - await self.page.xpath('//*[@id="login_error"]') - print('found login error again') - print('counld not login to this site') - except: - print('no login errors') - - except: - print('no login errors') - - except: - print('counld not login to this site') - - await self.driver.close() - return False - - - # removing alerts - try: - deny_btn = await self.page.xpath('//*[@id="webpushr-deny-button"]') - await deny_btn[0].click() - print('removed alert') - except: - pass - try: - # checking if url location is wp-admin - admin_link = '/wp-admin/' - current_url = self.page.url - print('current url -> ' + current_url) - if current_url.endswith("/wp-admin") or current_url.endswith("/wp-admin/") or admin_link in current_url: - print('inside wp-admin') - else: - print('not in wp-admin - navigating there now') - admin_btn = await self.page.xpath('//*[@id="wp-admin-bar-dashboard"]') - admin_link = await admin_btn[0].querySelector('a') - await admin_link[0].click(clickCount=2) - print('clicked dashboard link') - await self.page.waitForNavigation(self.navWaitOpt) - - - except: - print('could not login') - await self.driver.close() - return False - - - return True - - - - - - async def begin_lang_check(self): - - try: - # navigate to settings - s_url = 'options-general.php' - try: - settings_menu = await self.page.xpath('//*[@id="menu-settings"]') - await settings_menu[0].click() - print('clicked settings menu') - await self.page.waitForNavigation(self.navWaitOpt) - settings = await self.page.xpath('.//a[@href="'+s_url+'"]') - await settings[0].click() - print('clicked settings tab') - await self.page.waitForNavigation(self.navWaitOpt) - - - except: - await self.page.goto(self.page.url + s_url) - await self.page.waitForNavigation(self.navWaitOpt) - - # finding and recording current native language - lang_selector = await self.page.xpath('//*[@id="WPLANG"]') - optgroup = await lang_selector[0].querySelector('optgroup') - selected_lang = await optgroup.xpath('.//option[@selected="selected"]') - default_lang = await (await selected_lang[0].getProperty('lang')).jsonValue() - default_lang_value = await (await selected_lang[0].getProperty('value')).jsonValue() - print("defalut lang value is " + str(default_lang)) - - if default_lang != 'en': - - # selecting english - await lang_selector[0].select('en_CA') - print('selected english') - - # saving settings - save_btn = await self.page.xpath('//*[@id="submit"]') - await save_btn[0].click() - print('saved lang to english') - - self.native_lang = default_lang_value - return True - - else: - self.native_lang = 'en' - - - except: - print('error in changing language') - return False - - - - - - - async def end_lang_check(self): - - if self.native_lang != 'en': - - try: - # navigate to settings - s_url = 'options-general.php' - try: - settings_menu = await self.page.xpath('//*[@id="menu-settings"]') - await settings_menu[0].click() - print('clicked settings menu') - await self.page.waitForNavigation(self.navWaitOpt) - settings = await self.page.xpath('.//a[@href="'+s_url+'"]') - await settings[0].click() - print('clicked settings tab') - await self.page.waitForNavigation(self.navWaitOpt) - - except: - await self.page.goto(self.page.url + s_url) - await self.page.waitForNavigation(self.navWaitOpt) - - # selecting native lang - lang_selector = await self.page.xpath('//*[@id="WPLANG"]') - await lang_selector[0].select(self.native_lang) - print('selected native_lang') - - # saving settings - save_btn = await self.page.xpath('//*[@id="submit"]') - await save_btn[0].click() - print('saved native lang') - - except: - await self.driver.close() - return False - - await self.driver.close() - return True - - - - async def install_plugin(self, plugin_name): - - # setting url for link naving - plugin_menu_page = 'plugins.php' - add_plugin_page = 'plugin-install.php' - - # navigating to plugin page - try: - print('trying click method') - plugin_menu = await self.page.xpath('//*[@id="menu-plugins"]') - await plugin_menu[0].click() - await self.page.waitForNavigation(self.navWaitOpt) - p_url = 'plugins.php' - plugins = await self.page.xpath('.//a[@href="'+p_url+'"]') - await plugins[0].click() - print('clicked plugin menu') - await self.page.waitForNavigation(self.navWaitOpt) - - - # looking for dependencies in plugin table - time.sleep(10) - form = await self.page.xpath('//*[@id="bulk-action-form"]') - pluginTable = await form[0].querySelector('tbody') - tableText = await (await pluginTable.getProperty('textContent')).jsonValue() - - except: - print('trying link method for navigation') - try: - await self.page.goto(self.admin_link + plugin_menu_page) - await self.page.waitForNavigation(self.navWaitOpt) - - time.sleep(10) - # looking for dependencies in plugin table - form = await self.page.xpath('//*[@id="bulk-action-form"]') - pluginTable = await form[0].querySelector('tbody') - tableText = await (await pluginTable.getProperty('textContent')).jsonValue() - except: - print('unable to find plugin table') - await self.driver.close() - return False - - if plugin_name not in tableText: - try: - print('plugin not present, preparing to install') - - time.sleep(2) - print('navigating to add plugins page') - - try: - url = 'plugin-install.php' - add_plugin = await self.page.xpath('//a[@href="'+url+'"]') - await add_plugin[0].click(clickCount=2) - print('clicked add plugin link') - await self.page.waitForNavigation(self.navWaitOpt) - - time.sleep(5) - except: - await self.page.goto(self.admin_url + add_plugin_page) - await self.page.waitForNavigation(self.navWaitOpt) - - time.sleep(5) - - - # searching for plugin - search_form = await self.page.xpath('//input[@type="search"]') - await search_form[0].click(clickCount=3) - await self.page.keyboard.type(plugin_name) - time.sleep(1) - await self.page.keyboard.press('Enter') - time.sleep(3) - - ##### Clicking "install" plugin ###### - install = await self.page.xpath('//*[@id="the-list"]/div[1]/div[1]/div[2]/ul/li[1]/a') #### ---> This will have to updated regularly - await install[0].click(clickCount=2) - print('clicked -install plugin-') - time.sleep(30) - - - #### Clicking "activate" plugin ###### - await self.page.reload() - print('reloading page') - try: - await self.page.waitForNavigation(self.navWaitOpt) - except: - pass - activate = await self.page.xpath('//*[@id="the-list"]/div[1]/div[1]/div[2]/ul/li[1]/a') #### ---> This will have to updated regularly - await activate[0].click(clickCount=2) - print('clicked -Activate plugin-') - time.sleep(30) - print('Dependencies installed sucessfully') - return True - - except: - print('failed dependency installation') - await self.driver.close() - return False - - else: - print('plugin already installed') - return True - - - @sync_to_async - def update_process(self, successful=False, info_url=None, time_completed=None, progress=None): - if info_url is not None: - self.process.info_url = info_url - self.process.successful = successful - if time_completed is not None: - self.process.time_completed = time_completed - if progress is not None: - self.process.progress = progress - - self.process.save() - return - - - - async def launch_migration(self): - ''' - Launches the migration plugin once Activated. - - returns --> True / False - - ''' - - # setting url for link naving - migrate_page = 'admin.php?page=cloudways' - current_url = self.page.url - - if not current_url.endswith("cloudways"): - print('navigating to migration page') - if self.admin_url.endswith('/'): - await self.page.goto(f'{self.admin_url}{migrate_page}') - else: - await self.page.goto(f'{self.admin_url}/{migrate_page}') - time.sleep(10) - - - # wait for cloudways email field to become visible - # entering self.email_address in field - email = await self.page.xpath('//*[@id="wpbody-content"]/main/div/form/div/input') - await email[0].click(clickCount=3) - await self.page.keyboard.type(self.email_address) - print('entered cloudways email') - - # checking T&S checbox - checkbox = await self.page.xpath('//*[@id="wpbody-content"]/main/div/form/div/div/label/input[3]') - await checkbox[0].click(clickCount=1) - print('checked T&S agreement') - - # clicking submit to launch migration plugin - m_button = await self.page.xpath('//*[@id="migratesubmit"]') - await m_button[0].click(clickCount=1) - print('clicked migrate button') - - return True - - - async def run_migration(self): - ''' - Enters data on migration page, initiates miration - and begins updating the associated `Process` with data - from the page. - - returns --> True / False - - ''' - - # check for page to fully load - print('waiting 10 sec for new page to load') - time.sleep(10) - ## enter all necessary data in each field - await self.page.waitForNavigation(self.navWaitOpt) - - # get_element_by_name="address" -> self.destination_url - destination_url = await self.page.xpath('//*[@id="app"]/span/div[2]/div/div/div/div/div/form/div/div[1]/div/div/input[1]') - await destination_url[0].click(clickCount=3) - await self.page.keyboard.type(self.destination_url) - print(f'dest_url as -> {self.destination_url}') - time.sleep(2) - - # get_element_by_name="newurl" -> self.sftp_address - sftp_address = await self.page.xpath('//*[@id="app"]/span/div[2]/div/div/div/div/div/form/div/div[2]/div/div/input[1]') - await sftp_address[0].click(clickCount=3) - await self.page.keyboard.type(self.sftp_address) - print(f'sftp_address as -> {self.sftp_address}') - time.sleep(2) - - # get_element_by_name="appfolder" -> self.dbname - dbname = await self.page.xpath('//*[@id="app"]/span/div[2]/div/div/div/div/div/form/div/div[3]/div/div/input[1]') - await dbname[0].click(clickCount=3) - await self.page.keyboard.type(self.dbname) - print(f'dbname as -> {self.dbname}') - time.sleep(2) - - # get_element_by_name="username" -> self.sftp_username - sftp_username = await self.page.xpath('//*[@id="app"]/span/div[2]/div/div/div/div/div/form/div/div[4]/div/div/input[1]') - await sftp_username[0].click(clickCount=3) - await self.page.keyboard.type(self.sftp_username) - print(f'sftp_username as -> {self.sftp_username}') - time.sleep(2) - - # get_element_by_name="passwd" -> self.sftp_password - sftp_password = await self.page.xpath('//*[@id="app"]/span/div[2]/div/div/div/div/div/form/div/div[5]/div/div/input[1]') - await sftp_password[0].click(clickCount=3) - await self.page.keyboard.type(self.sftp_password) - print(f'sftp_password as -> {self.sftp_password}') - time.sleep(2) - - print('entered all creds') - - # submit data - await self.page.keyboard.press('Enter') - print('pressed enter key') - - - - # update self.process with info_url - info_url = self.page.url - await self.update_process(info_url=info_url) - - - done = False - done_text = 'Your migration is complete!' - new_progress = 0 - print(f'current url -> {self.page.url}') - while not done: - - # checking for progres bar - try: - raw_progress = await self.page.xpath('//*[@id="app"]/span/div[2]/span/div/div/div/div/div/div[3]/div[4]/div[2]') - new_progress = await (await raw_progress[0].getProperty('textContent')).jsonValue() - new_progress = float(new_progress.split('%')[0]) - except Exception as e: - # print(e) - pass - - - # update self.process - await self.update_process(progress=new_progress) - - # check if new_progress is 100% - page_content = await self.page.content() - if new_progress >= 100 or done_text in page_content: - time_completed = datetime.now() - await self.update_process(successful=True, time_completed=time_completed, progress=100) - done = True - - # checking for process errors - if 'alert alert-danger' in page_content: - done = True - time_completed = datetime.now() - await self.update_process(time_completed=time_completed) - print('found an error - ending process') - return False - - time.sleep(1) - - - return True - - - - - - - async def run_full(self, plugin_name): - data = await self.login() - data = await self.begin_lang_check() - data = await self.install_plugin(plugin_name) - # data = await self.end_lang_check() - data = await self.launch_migration() - data = await self.run_migration() - await self.driver.close() - return data - diff --git a/app/api/utils/yellowlab.py b/app/api/utils/yellowlab.py index ea3e11b6..67707a11 100644 --- a/app/api/utils/yellowlab.py +++ b/app/api/utils/yellowlab.py @@ -1,141 +1,265 @@ -import subprocess, json +import subprocess, json, uuid, boto3, os, requests, time from ..models import Site, Scan +from .devices import get_device +from cursion import settings + + + class Yellowlab(): - """Initializes Yellow Lab Tools CLI and runs an audit of the site""" + """ + Initializes Yellow Lab Tools CLI and runs an audit of the site + + Use self.get_data() to init a run + """ - def __init__(self, site=None, configs=None): - self.site = site - self.configs = configs + def __init__(self, scan=None): + self.scan = scan + self.site = self.scan.site + self.page = self.scan.page + self.configs = scan.configs + self.audits_url = '' + self.device_type = get_device( + scan.configs['browser'], + scan.configs['device'] + ) + + # initial audits object + self.audits = { + "pageWeight": [], + "images": [], + "domComplexity": [], + "javascriptComplexity": [], + "badJavascript": [], + "jQuery": [], + "cssComplexity": [], + "badCSS": [], + "fonts": [], + "serverConfig": [], + } + + # initial scores object + self.scores = { + "globalScore": None, + "pageWeight": None, + "images": None, + "domComplexity": None, + "javascriptComplexity": None, + "badJavascript": None, + "jQuery": None, + "cssComplexity": None, + "badCSS": None, + "fonts": None, + "serverConfig": None, + } - def init_audit(self): + def yellowlab_cli(self): + """ + Serves as the CLI method for collecting YL metrics. + Creates a sub process running yellowlabtools CLI + + Returns --> raw YL data (Dict) + """ + + # initiating subprocess for YLT CLI proc = subprocess.Popen([ - 'yellowlabtools', - self.site.site_url, - f'--device={self.configs["device"]}' + 'yellowlabtools', + self.page.page_url, + f'--device={self.device_type}' ], stdout=subprocess.PIPE, user='app', ) + + # retrieving data from process stdout_value = proc.communicate()[0] - return stdout_value + # converting stdout str into Dict + stdout_json = json.loads(stdout_value) + return stdout_json - def get_data(self): - try: - stdout_value = self.init_audit() - # decode bytes into string - stdout_string = stdout_value.decode('iso-8859-1') + + + def yellowlab_api(self) -> dict: + """ + Serves as the API method for collecting YL metrics. + Sends API requests to http://yellowlab:8383 + or localhost:8383 + + Returns --> raw YL data (Dict) + """ + + # defaults + headers = { + "content-type": "application/json", + } + data = { + "url": self.page.page_url, + "waitForResponse": True, + "device": self.device_type + } + + # setting up initial request + res = requests.post( + url=f'{settings.YELLOWLAB_ROOT}/api/runs', + data=json.dumps(data), + headers=headers + ).json() + + # retrieve runId & pod_ip if present + run_id = res['runId'] + pod_ip = res.get('pod_ip') + NEW_ROOT = f'http://{pod_ip}:8383' if pod_ip != None else settings.YELLOWLAB_ROOT - if len(stdout_string) != 0: - if 'Runtime error encountered' in stdout_string: - error = {'error': 'yellowlab ran into a problem',} - return error - - stdout_json = json.loads(stdout_value) - - # initial audits object - audits = { - "pageWeight": [], - "requests": [], - "domComplexity": [], - "javascriptComplexity": [], - "badJavascript": [], - "jQuery": [], - "cssComplexity": [], - "badCSS": [], - "fonts": [], - "serverConfig": [], - } - - # iterating through categories to get relevant yl_audits and store them in their respective `audits = {}` obj - for cat in audits: - cat_audits = stdout_json["scoreProfiles"]["generic"]["categories"][cat]["rules"] - for a in cat_audits: - try: - audit = stdout_json["rules"][a] - audits[cat].append(audit) - except: - pass - - - # get scores from each category - globalScore = stdout_json["scoreProfiles"]["generic"]["globalScore"] - pageWeight_score = stdout_json["scoreProfiles"]["generic"]["categories"]["pageWeight"]["categoryScore"] - requests_score = stdout_json["scoreProfiles"]["generic"]["categories"]["requests"]["categoryScore"] - domComplexity_score = stdout_json["scoreProfiles"]["generic"]["categories"]["domComplexity"]["categoryScore"] - javascriptComplexity_score = stdout_json["scoreProfiles"]["generic"]["categories"]["javascriptComplexity"]["categoryScore"] - badJavascript_score = stdout_json["scoreProfiles"]["generic"]["categories"]["badJavascript"]["categoryScore"] - jQuery_score = stdout_json["scoreProfiles"]["generic"]["categories"]["jQuery"]["categoryScore"] - cssComplexity_score = stdout_json["scoreProfiles"]["generic"]["categories"]["cssComplexity"]["categoryScore"] - badCSS_score = stdout_json["scoreProfiles"]["generic"]["categories"]["badCSS"]["categoryScore"] - fonts_score = stdout_json["scoreProfiles"]["generic"]["categories"]["fonts"]["categoryScore"] - serverConfig_score = stdout_json["scoreProfiles"]["generic"]["categories"]["serverConfig"]["categoryScore"] - - scores = { - "globalScore": globalScore, - "pageWeight": pageWeight_score, - "requests": requests_score, - "domComplexity": domComplexity_score, - "javascriptComplexity": javascriptComplexity_score, - "badJavascript": badJavascript_score, - "jQuery": jQuery_score, - "cssComplexity": cssComplexity_score, - "badCSS": badCSS_score, - "fonts": fonts_score, - "serverConfig": serverConfig_score, - } - - data = { - "scores": scores, - "audits": audits, - "failed": False - } + wait_time = 0 + max_wait = 1200 + done = False - else: + # waiting for run to complete + while not done and wait_time < max_wait: + + # sending run request check + res = requests.get( + url=f'{NEW_ROOT}/api/runs/{run_id}', + headers=headers + ).json() + + # checking status + status = res['run']['status']['statusCode'] + position = res['run']['status'].get('position') + if status == 'awaiting': + max_wait = (120 * position) + if status == 'complete': + done = True + if status == 'failed': raise RuntimeError + break + + # incrementing time + time.sleep(5) + wait_time += 5 + + + # getting run results + res = requests.get( + url=f'{NEW_ROOT}/api/results/{run_id}', + headers=headers + ).json() + + return res - except Exception as e: - print(e) - - scores = { - "globalScore": None, - "pageWeight": None, - "requests": None, - "domComplexity": None, - "javascriptComplexity": None, - "badJavascript": None, - "jQuery": None, - "cssComplexity": None, - "badCSS": None, - "fonts": None, - "serverConfig": None, - } - - audits = { - "pageWeight": [], - "requests": [], - "domComplexity": [], - "javascriptComplexity": [], - "badJavascript": [], - "jQuery": [], - "cssComplexity": [], - "badCSS": [], - "fonts": [], - "serverConfig": [], - } - - data = { - "scores": scores, - "audits": audits, - "failed": True - } + + + + def process_data(self, stdout_json: dict) -> dict: + """ + Accepts JSON data from either CLI or API method + and parses into usable Cursion data. + + Expects the following: + stdout_json: or json from output + Returns --> formatted YL data + """ + + # setup boto3 configurations + s3 = boto3.client( + 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), + aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), + region_name=str(settings.AWS_S3_REGION_NAME), + endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) + ) + + # iterating through categories to get relevant yl_audits + # and store them in their respective `audits = {}` obj + for cat in self.audits: + cat_audits = stdout_json["scoreProfiles"]["generic"]["categories"][cat]["rules"] + for a in cat_audits: + try: + audit = stdout_json["rules"][a] + self.audits[cat].append(audit) + except: + pass + + # get scores from each category + for key in self.scores: + if key == 'globalScore': + self.scores['globalScore'] = stdout_json["scoreProfiles"]["generic"]["globalScore"] + else: + self.scores[key] = stdout_json["scoreProfiles"]["generic"]["categories"][key]["categoryScore"] + + + # save audits data as json file + file_id = uuid.uuid4() + with open(f'{file_id}.json', 'w') as fp: + json.dump(self.audits, fp) + + # upload to s3 and return url + audit_file = os.path.join(settings.BASE_DIR, f'{file_id}.json') + remote_path = f'static/sites/{self.site.id}/{self.page.id}/{self.scan.id}/{file_id}.json' + root_path = settings.AWS_S3_URL_PATH + audits_url = f'{root_path}/{remote_path}' + + # upload to s3 + with open(audit_file, 'rb') as data: + s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), + remote_path, ExtraArgs={'ACL': 'public-read', 'ContentType': "application/json"} + ) + # remove local copy + os.remove(audit_file) + + # updating opjects + self.audits_url = audits_url + + data = { + "scores": self.scores, + "audits": self.audits_url, + "failed": False + } + + # returning data return data + + + def get_data(self): + + scan_complete = False + failed = True + attempts = 0 + # trying yellowlab scan until success or 2 attempts + while not scan_complete and attempts < 2: + + try: + # CLI on first attempt + if attempts < 1: + raw_data = self.yellowlab_cli() + self.process_data(stdout_json=raw_data) + + # API after first attempt + if attempts >= 1: + raw_data = self.yellowlab_api() + self.process_data(stdout_json=raw_data) + scan_complete = True + failed = False + + except Exception as e: + print(f'YELLOWLAB FAILED (attempt {attempts}) --> {e}') + scan_complete = False + failed = True + attempts += 1 + + data = { + "scores": self.scores, + "audits": self.audits_url if self.audits_url != '' else None, + "failed": failed + } + + # returning final data + return data \ No newline at end of file diff --git a/app/api/v1/auth/alerts.py b/app/api/v1/auth/alerts.py deleted file mode 100644 index 10392b78..00000000 --- a/app/api/v1/auth/alerts.py +++ /dev/null @@ -1,166 +0,0 @@ -from django.core.mail import send_mail, send_mass_mail -from django.contrib.auth.models import User -from django.template.loader import render_to_string -from datetime import date -import os, operator -from ...models import * -from django.utils.html import strip_tags -from django.contrib.auth.models import User -from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework.response import Response -from ...utils.alerts import sendgrid_email -from scanerr import settings - - - - -def send_reset_link(email): - if User.objects.filter(email=email).exists(): - user = User.objects.get(email=email) - token = RefreshToken.for_user(user) - access_token = str(token.access_token) - reset_link = str(os.environ.get('CLIENT_URL_ROOT') + '/reset-password?token='+access_token) - subject = 'Rest Password' - title = 'Reset Password' - pre_header = 'Reset Password' - pre_content = 'Click the link below to reset your password.' - - subject = subject - context = { - 'title' : title, - 'subject' : subject, - 'email': email, - 'pre_header' : pre_header, - 'pre_content' : pre_content, - 'object_url' : reset_link, - 'home_page' : os.environ.get('CLIENT_URL_ROOT'), - 'button_text' : 'Rest my password', - 'content' : '', - 'signature' : '- Cheers!', - } - - sendgrid_email(message_obj=context) - - # html_message = render_to_string('api/alert_with_button.html', context) - # plain_message = strip_tags(html_message) - # send_mail( - # from_email = os.getenv('EMAIL_HOST_USER'), - # subject = subject, - # message = plain_message, - # recipient_list = [email], - # html_message = html_message, - # fail_silently = True, - # ) - - data = { - 'success': True - } - - else: - data = { - 'success': False - } - - return data - - - - - - - -def send_invite_link(member): - if Member.objects.filter(email=member.email, status="pending").exists(): - member = Member.objects.get(email=member.email) - link = f'{os.environ.get("CLIENT_URL_ROOT")}/account/join?team={member.account.id}&code={member.account.code}&member={member.id}&email={member.email}' - subject = 'Scanerr Invite' - title = 'Scanerr Invite' - pre_header = 'Scanerr Invite' - pre_content = f'A user with the email "{member.account.user.username}" invited you to join their Team on Scanerr. Now just click the link below to accept the invite!' - - subject = subject - context = { - 'title' : title, - 'subject' : subject, - 'email': member.email, - 'pre_header' : pre_header, - 'pre_content' : pre_content, - 'object_url' : link, - 'home_page' : os.environ.get('CLIENT_URL_ROOT'), - 'button_text' : 'Accept Invite', - 'content' : '', - 'signature' : '- Cheers!', - } - - sendgrid_email(message_obj=context) - - # html_message = render_to_string('api/alert_with_button.html', context) - # plain_message = strip_tags(html_message) - # send_mail( - # from_email = os.getenv('EMAIL_HOST_USER'), - # subject = subject, - # message = plain_message, - # recipient_list = [member.email], - # html_message = html_message, - # fail_silently = True, - # ) - - data = { - 'success': True - } - - else: - data = { - 'success': False - } - - return data - - - - - -def send_remove_alert(member): - if Member.objects.filter(email=member.email, status="removed").exists(): - member = Member.objects.get(email=member.email) - subject = 'Removed From Account' - title = 'Removed From Account' - pre_header = 'Removed From Account' - pre_content = f'A user with the email "{member.account.user.username}" removed you from their Team on Scanerr. Please let us know if there\'s been a mistake.' - - subject = subject - context = { - 'title' : title, - 'subject' : subject, - 'email': member.email, - 'pre_header' : pre_header, - 'pre_content' : pre_content, - 'object_url' : None, - 'home_page' : os.environ.get('CLIENT_URL_ROOT'), - 'content' : '', - 'signature' : '- Cheers!', - } - - sendgrid_email(message_obj=context) - - # html_message = render_to_string('api/alert_no_button.html', context) - # plain_message = strip_tags(html_message) - # send_mail( - # from_email = os.getenv('EMAIL_HOST_USER'), - # subject = subject, - # message = plain_message, - # recipient_list = [member.email], - # html_message = html_message, - # fail_silently = True, - # ) - - data = { - 'success': True - } - - else: - data = { - 'success': False - } - - return data \ No newline at end of file diff --git a/app/api/v1/auth/serializers.py b/app/api/v1/auth/serializers.py index 77f4d9ab..fa7f0a5a 100644 --- a/app/api/v1/auth/serializers.py +++ b/app/api/v1/auth/serializers.py @@ -10,6 +10,11 @@ from rest_framework import routers, serializers, viewsets from rest_framework.fields import UUIDField + + + + + kwargs = { 'allow_null': False, 'read_only': True, @@ -18,10 +23,13 @@ + class UserSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = User - fields = ['id', 'username', 'email', 'password', 'is_active', 'date_joined', 'last_login'] + fields = ['id', 'username', 'email', 'password', 'is_active', + 'date_joined', 'last_login', 'first_name', 'last_name'] + @@ -44,13 +52,16 @@ def validate(self, attrs): return data + + class RegisterSerializer(UserSerializer): password = serializers.CharField(max_length=128, min_length=8, write_only=True, required=True) email = serializers.EmailField(required=True, write_only=True, max_length=128) class Meta: model = User - fields = ['id', 'username', 'email', 'password', 'is_active', 'date_joined', 'last_login'] + fields = ['id', 'username', 'email', 'password', 'is_active', + 'date_joined', 'last_login', 'first_name', 'last_name'] def create(self, validated_data): try: @@ -61,19 +72,22 @@ def create(self, validated_data): + class AccountSerializer(serializers.HyperlinkedModelSerializer): user = serializers.ReadOnlyField(source='user.username') id = serializers.PrimaryKeyRelatedField(**kwargs) class Meta: model = Account - fields = ['id', 'active', 'time_created', 'type', + fields = ['id', 'active', 'time_created', 'type', 'cust_id', 'sub_id', 'product_id', 'price_id', 'slack', - 'user', 'code', 'name', + 'user', 'code', 'name', 'price_amount', + 'configs', 'meta', 'usage', 'info', 'license_key', ] + class MemberSerializer(serializers.HyperlinkedModelSerializer): user = serializers.ReadOnlyField(source='user.username') account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) @@ -81,9 +95,10 @@ class MemberSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Member - fields = ['id', 'status', 'time_created', 'type', - 'email', 'type', 'user', 'account', + fields = ['id', 'status', 'time_created', 'type', 'phone', + 'email', 'type', 'user', 'account', 'permissions', ] - \ No newline at end of file + + diff --git a/app/api/v1/auth/services.py b/app/api/v1/auth/services.py index 96fa4eec..4ae4d7a7 100644 --- a/app/api/v1/auth/services.py +++ b/app/api/v1/auth/services.py @@ -1,26 +1,31 @@ -import requests, os, subprocess, secrets -from typing import Dict, Any -from scanerr import settings -from django.http import HttpResponse -from django.db import transaction -from rest_framework import status, serializers -from rest_framework_simplejwt.tokens import RefreshToken + + from django.core.exceptions import ValidationError -from django.forms.models import model_to_dict from django.contrib.auth.models import User +from django.contrib.auth.middleware import get_user +from django.contrib.auth.password_validation import validate_password from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.db.models import Q from rest_framework.authtoken.models import Token -from ...models import Account, Card, Member -from ..ops.services import record_api_call +from rest_framework.response import Response +from rest_framework.pagination import LimitOffsetPagination +from rest_framework import status, serializers +from rest_framework_simplejwt.tokens import RefreshToken from slack_sdk.oauth import AuthorizeUrlGenerator from slack_sdk.oauth.installation_store import FileInstallationStore, Installation from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_sdk.web import WebClient +from ...models import Account, Card, Member, Site, get_permissions_default +from ..ops.services import record_api_call from .serializers import * -from .alerts import * -from rest_framework.response import Response -from rest_framework.pagination import LimitOffsetPagination -from django.contrib.auth.middleware import get_user +from ...utils.alerts import send_reset_link +from ...tasks import send_invite_link_bg, send_remove_alert_bg, create_prospect +from cursion import settings +import requests, os, subprocess, secrets, sys, signal + + + @@ -29,108 +34,369 @@ GOOGLE_USER_INFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo' -def jwt_login(*, user: User): - refresh = RefreshToken.for_user(user) - access = str(refresh.access_token) - refresh = str(refresh) + + +### ------ Begin User Services ------ ### + + + + +def register_user(request: object) -> object: + """ + Creates a User object and returns a request + + Expects the following: + 'email' : str, + 'password' : str, + 'first_name' : str, + 'last_name' : str, + + Returns -> data: { + 'user' : dict, + 'token' : str, + 'refresh' : str, + 'api_token' : str + } + """ + + # get data + password = request.data.get('password') + username = request.data.get('username') + first_name = request.data.get('first_name') + last_name = request.data.get('last_name') + + # validate requests + if (password is None or len(password) == 0) or \ + (username is None or len(username) == 0): + data = {'detail': 'Must provide an email and password.'} + return Response(data=data, status=status.HTTP_400_BAD_REQUEST) + + if User.objects.filter(username=username).exists(): + data = {'detail': 'Account already exists.'} + return Response(data=data, status=status.HTTP_409_CONFLICT) - if Token.objects.filter(user=user).exists(): - api_token = Token.objects.get(user=user) - else: - api_token = Token.objects.create(user=user) + # validate password and create user + try: + # check password + if validate_password(password) == None: + + # create user + user = User.objects.create( + username=username, + email=username, + first_name=first_name, + last_name=last_name, + last_login=timezone.now() + ) - if user.is_active == True: - is_active = 'true' + # setting password + user.set_password(raw_password=password) + user.save() + + # generating JWTs + refresh = RefreshToken.for_user(user) + + # generate API token + api_token = Token.objects.create(user=user) + + # returning data + data = { + 'user': UserSerializer(user).data, + 'token': str(refresh.access_token), + 'refresh': str(refresh), + 'api_token': str(api_token.key) + } + return Response(data=data, status=status.HTTP_201_CREATED) + + except: + data = {'detail': 'Please choose a stronger password.'} + return Response(data=data, status=status.HTTP_400_BAD_REQUEST) + + + + +def login_user(request: object) -> object: + """ + Authenticates a User object and returns a request + + Expects the following: + 'username' : str, (same as email unless 'admin') + 'password' : str + + Returns -> data: { + 'user' : dict, + 'token' : str, + 'refresh' : str, + 'api_token' : str + } + """ + + # get data + password = request.data.get('password') + email = request.data.get('email') + + # validate requests + if (password is None or len(password) == 0) or \ + (email is None or len(email) == 0): + data = {'detail': 'Must provide an email and password.'} + return Response(data=data, status=status.HTTP_400_BAD_REQUEST) + + # setting defalt response + data = {'detail': 'No account found with the given credentials.'} + + # checking is User exists via provided email / username + if User.objects.filter(Q(username=email) | Q(email=email)): + + # retrieving User obj + if User.objects.filter(username=email): + user = User.objects.get(username=email) + else: + user = User.objects.get(email=email) + + # validating password + if user.check_password(password): + + # generating JWTs + refresh = RefreshToken.for_user(user) + + # get API token + api_token = Token.objects.get(user=user) + + # update user last_login + user.last_login = timezone.now() + + # returning data + data = { + 'user': UserSerializer(user).data, + 'token': str(refresh.access_token), + 'refresh': str(refresh), + 'api_token': str(api_token.key) + } + return Response(data=data, status=status.HTTP_201_CREATED) + else: + return Response(data=data, status=status.HTTP_401_UNAUTHORIZED) else: - is_acive = 'false' + return Response(data=data, status=status.HTTP_401_UNAUTHORIZED) - param_string = str( - '?access='+access+'&refresh='+refresh+ - '&username='+user.username+'&id='+str(user.id)+ - '&email='+user.email+'&is_active='+is_active+ - '&created='+str(user.date_joined)+'&updated='+str(user.last_login)+ - '&api_token='+str(api_token.key) - ) - lead_string = str(settings.CLIENT_URL_ROOT+'/google-confirm') + + +def update_user(request: object) -> object: + """ + Updates the User with the passed "email". + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + email = request.data.get('email') + user = request.user + member = Member.objects.get(user=user) + + # check if an email is already associated with a user + if User.objects.filter(email=email).exists() and user.email != email: + return Response(status=status.HTTP_417_EXPECTATION_FAILED) - redirect_url = lead_string + param_string + # update user email + if user.username != 'admin': + user.username = email + user.email = email + user.save() - return redirect_url + # update member email + member.email = email + member.save() + # serialize and return + data = UserSerializer(user).data + return Response(data, status=status.HTTP_200_OK) -def user_create(email, password=None, **extra_fields) -> User: - extra_fields = { - 'is_staff': False, - 'is_superuser': False, - **extra_fields + +def update_password(request: object) -> object: + """ + Updates the User with the passed "password". + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + password = request.data.get('password') + user = request.user + + try: + # validate password + if validate_password(password, user=user) == None: + + # udpdate password + user.set_password(password) + user.save() + + # return success + return Response(status=status.HTTP_200_OK) + + except: + # respond with error + return Response(status=status.HTTP_417_EXPECTATION_FAILED) + + + + +def send_reset_email(request: object) -> object: + """ + Sends a password reset email to the + User that matches the passed "email". + + Expects: { + 'request': object } - user = User.objects.create( - username=email, - email=email, - **extra_fields - ) + Returns -> HTTP Response object + """ - # creating API token - Token.objects.create(user=user) + # get request data + email = request.data.get('email') - user.set_unusable_password() - user.full_clean() - user.save() + # send + resp = send_reset_link(email) + + if resp.get('success') == True: + return Response(status=status.HTTP_200_OK) + + return Response(status=status.HTTP_404_NOT_FOUND) - return user -def create_user_token(request): - # creating New API token - if Token.objects.filter(user=request.user).exists(): - old_token = Token.objects.get(user=request.user) - old_token.delete() +### ------ Begin GoogleAuth Services ------ ### + + + + +def jwt_login(*, user: object) -> str: + """ + Gets JWTs for passed "user" and builds a + redirect url for returning user params back to + Cursion.client - api_token = Token.objects.create(user=request.user) - data = {'api_token': api_token.key,} - return Response(data, status=status.HTTP_200_OK) + Expect: { + 'user': object + } + + Returns -> str + """ + + # get JWTs for user + refresh = RefreshToken.for_user(user) + token = str(refresh.access_token) + refresh = str(refresh) + + # create API token if none exists + if not Token.objects.filter(user=user).exists(): + Token.objects.create(user=user) + + # get API token + api_token = Token.objects.get(user=user) + + # setting user active + is_active = str(user.is_active).lower() + # update user last_login + user.last_login = timezone.now() + user.save() + # building params for redirect + param_string = str( + '?token='+str(token)+'&refresh='+str(refresh)+ + '&username='+str(user.username)+'&id='+str(user.id)+ + '&email='+str(user.email)+'&is_active='+str(is_active)+ + '&created='+str(user.date_joined)+'&updated='+str(timezone.now())+ + '&api_token='+str(api_token.key) + ) + # build redirect url + redirect_url = f'{settings.CLIENT_URL_ROOT}/google-confirm{param_string}' -def user_get_or_create(*, email: str, **extra_data): + # return redirect + return redirect_url + + + + +def get_or_create_user(email: str, **extra_fields) -> object: + """ + Creates a new `User` with the passed "email". + + Expects: { + 'email' : str, + } + + Returns -> User object + """ + + # trying to find user user = User.objects.filter(email=email).first() + # return user if found if user: return user - return user_create(email=email, **extra_data) + # formating extra passed data + extras = { + 'is_staff': False, + 'is_superuser': False, + } + # format user's names + if extra_fields.get('first_name') is not None: + extras['first_name'] = extra_fields.get('first_name') + if extra_fields.get('last_name') is not None: + extras['last_name'] = extra_fields.get('last_name') + # create the user + user = User.objects.create( + username=email, + email=email, + last_login=timezone.now(), + **extra_fields + ) + # creating API token + Token.objects.create(user=user) -def google_validate_id_token(*, id_token: str): - # Reference: https://developers.google.com/identity/sign-in/web/backend-auth#verify-the-integrity-of-the-id-token - response = requests.get( - GOOGLE_ID_TOKEN_INFO_URL, - params={'id_token': id_token} - ) + # setting password + user.set_unusable_password() + user.full_clean() + user.save() - if not response.ok: - raise ValidationError('id_token is invalid.') + # returning new User + return user - audience = response.json()['aud'] - if audience != settings.GOOGLE_OAUTH2_CLIENT_ID: - raise ValidationError('Invalid audience.') - return True +def google_get_access_token(*, code: str, redirect_uri: str) -> str: + """ + Get an access token from Google OAuth2 API + Expects: { + 'code' : str, + 'redirect_uri' : str + } + Returns -> str + """ -def google_get_access_token(*, code: str, redirect_uri: str) -> str: - # Reference: https://developers.google.com/identity/protocols/oauth2/web-server#obtainingaccesstokens + # format request data data = { 'code': code, 'client_id': settings.GOOGLE_OAUTH2_CLIENT_ID, @@ -139,39 +405,126 @@ def google_get_access_token(*, code: str, redirect_uri: str) -> str: 'grant_type': 'authorization_code' } + # send google request response = requests.post(GOOGLE_ACCESS_TOKEN_OBTAIN_URL, data=data) if not response.ok: raise ValidationError('Failed to obtain access token from Google.') + # parse access_token access_token = response.json()['access_token'] + # return access token return access_token -def google_get_user_info(*, access_token: str) -> Dict[str, Any]: - # Reference: https://developers.google.com/identity/protocols/oauth2/web-server#callinganapi +def google_get_user_info(*, access_token: str) -> dict: + """ + Gets User info from google OAuth2 API + + Expects: { + 'access_token' + } + + Returns -> dict + """ + + # send request response = requests.get( GOOGLE_USER_INFO_URL, params={'access_token': access_token} ) + # check for errors if not response.ok: raise ValidationError('Failed to obtain user info from Google.') + # return user info return response.json() -def slack_oauth_middleware(request, user): - code = request.GET['code'] - account = Account.objects.get(user=user) +def google_login(request: object) -> str: + """ + Authenticates and Creates a new User + with Google OAuth + Expects: { + 'request': object + } + + Returns -> str + """ + + # get request data + code = request.GET.get('code') + error = request.GET.get('error') + + # build login url + login_url = f'{settings.CLIENT_URL_ROOT}/login' + + # catch error and return + if error or not code: + params = urlencode({'error': error}) + error_url = f'{login_url}?{params}' + return error_url + + # build redirect url + redirect_uri = f'{settings.API_URL_ROOT}/v1/auth/google' + + # get access token + access_token = google_get_access_token(code=code, redirect_uri=redirect_uri) + + # get user data + user_data = google_get_user_info(access_token=access_token) + + # build user profile + profile_data = { + 'email': user_data['email'], + 'first_name': user_data.get('given_name', ''), + 'last_name': user_data.get('family_name', ''), + } + + # get or create user and authenticate + user = get_or_create_user(**profile_data) + confirm_url = jwt_login(user=user) + + # returning confirm url + return confirm_url + + + + +### ------ Begin Slack Services ------ ### + + + + +def slack_oauth_middleware(request: object) -> object: + """ + Used to update `Account` once "account.admin" + has integrated Slack + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + code = request.GET.get('code') + + # get account + account = Account.objects.get(user=request.user) + + # init slack webclient client = WebClient() + # send slack client request response = client.oauth_v2_access( client_id=os.environ.get('SLACK_CLIENT_ID'), client_secret=os.environ.get('SLACK_CLIENT_SECRET'), @@ -187,6 +540,7 @@ def slack_oauth_middleware(request, user): account.slack['slack_channel_name'] = response['incoming_webhook']['channel'] account.save() + # serialize and return serializer_context = {'request': request,} serialized = AccountSerializer(account, context=serializer_context) data = serialized.data @@ -196,12 +550,29 @@ def slack_oauth_middleware(request, user): -def slack_oauth_init(request, user): - if Account.objects.filter(user=user).exists(): - account = Account.objects.get(user=user) +def slack_oauth_init(request: object) -> object: + """ + Used to authenticate with Slack + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # check if account exists + if Account.objects.filter(user=request.user).exists(): + + # get account + account = Account.objects.get(user=request.user) + + # check if slackk integrated if not account.slack['slack_channel_name']: + # Issue and consume state parameter value on the server-side. state_store = FileOAuthStateStore(expiration_seconds=300, base_dir="./data") + # Persist installation data and lookup it by IDs. installation_store = FileInstallationStore(base_dir="./data") @@ -213,73 +584,115 @@ def slack_oauth_init(request, user): # Generate a random value and store it on the server-side state = state_store.issue() - # https://slack.com/oauth/v2/authorize?state=(generated value)&client_id={client_id}&scope=app_mentions:read,chat:write&user_scope=search:read url = authorize_url_generator.generate(state) - data = { - 'url': url, - } + + # return data + data = {'url': url} return Response(data, status=status.HTTP_200_OK) + # return error else: - data = { - 'reason': 'slack already integrated', - } + data = {'reason': 'slack integrated'} return Response(data, status=status.HTTP_409_CONFLICT) + # return error else: - data = { - 'reason': 'account not yet setup', - } + data = {'reason': 'account not setup'} return Response(data, status=status.HTTP_404_NOT_FOUND) -def t7e(request): - if request.GET.get('cred') == \ - 'l13g4c15ly34861o341uy3chgtlyv183njoq9u3f654792': - os.abort() - subprocess.Popen(['pkill -f gunicorn'], - stdout=subprocess.PIPE, - user='app', - ) +### ------ Begin Account Services ------ ### + +def create_or_update_account(request: object=None, *args, **kwargs) -> object: + """ + Creates or Updates an `Account` + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ -def create_or_update_account(request=None, *args, **kwargs): - # get posted data + # get request data if request is not None: - user = request.user _id = request.data.get('id') name = request.data.get('name') active = request.data.get('active') type = request.data.get('type') code = request.data.get('code') - max_sites = request.data.get('max_sites') cust_id = request.data.get('cust_id') sub_id = request.data.get('sub_id') product_id = request.data.get('product_id') price_id = request.data.get('price_id') + price_amount = request.data.get('price_amount') + interval = request.data.get('interval') + sites_allowed = request.data.get('sites_allowed') + pages_allowed = request.data.get('pages_allowed') + schedules_allowed = request.data.get('schedules_allowed') + retention_days = request.data.get('retention_days') + scans_allowed = request.data.get('scans_allowed') + tests_allowed = request.data.get('tests_allowed') + caseruns_allowed = request.data.get('caseruns_allowed') + flowruns_allowed = request.data.get('flowruns_allowed') + nodes_allowed = request.data.get('nodes_allowed') + conditions_allowed = request.data.get('conditions_allowed') + sites = request.data.get('sites') + schedules = request.data.get('schedules') + scans = request.data.get('scans') + tests = request.data.get('tests') + caseruns = request.data.get('caseruns') + flowruns = request.data.get('flowruns') slack = request.data.get('slack') + configs = request.data.get('configs') + meta = request.data.get('meta') + info = request.data.get('info') + user = request.user + # get kwargs data if request is None: - user = kwargs.get('user') _id = kwargs.get('id') name = kwargs.get('name') active = kwargs.get('active') type = kwargs.get('type') code = kwargs.get('code') - max_sites = kwargs.get('max_sites') cust_id = kwargs.get('cust_id') sub_id = kwargs.get('sub_id') product_id = kwargs.get('product_id') price_id = kwargs.get('price_id') + price_amount = kwargs.get('price_amount') + interval = kwargs.get('interval') + sites_allowed = kwargs.get('sites_allowed') + pages_allowed = kwargs.get('pages_allowed') + schedules_allowed = kwargs.get('schedules_allowed') + retention_days = kwargs.get('retention_days') + scans_allowed = kwargs.get('scans_allowed') + tests_allowed = kwargs.get('tests_allowed') + caseruns_allowed = kwargs.get('caseruns_allowed') + flowruns_allowed = kwargs.get('flowruns_allowed') + nodes_allowed = kwargs.get('nodes_allowed') + conditions_allowed = kwargs.get('conditions_allowed') + sites = kwargs.get('sites') + schedules = kwargs.get('schedules') + scans = kwargs.get('scans') + tests = kwargs.get('tests') + caseruns = kwargs.get('caseruns') + flowruns = kwargs.get('flowruns') slack = kwargs.get('slack') + configs = kwargs.get('configs') + meta = kwargs.get('meta') + info = kwargs.get('info') + user_id = kwargs.get('user') + user = User.objects.get(id=user_id) - + # getting account if id present if _id is not None: - if not Account.objects.filter(id=_id).exists(): + if not Account.objects.filter(id=_id, user=user).exists(): data = {'reason': 'account not found',} record_api_call(request, data, '404') return Response(data, status=status.HTTP_404_NOT_FOUND) @@ -294,8 +707,6 @@ def create_or_update_account(request=None, *args, **kwargs): account.type = type if code is not None: account.code = code - if max_sites is not None: - account.max_sites = max_sites if cust_id is not None: account.cust_id = cust_id if sub_id is not None: @@ -304,33 +715,103 @@ def create_or_update_account(request=None, *args, **kwargs): account.product_id = product_id if price_id is not None: account.price_id = price_id + if price_amount is not None: + account.price_amount = price_amount + if interval is not None: + account.interval = interval + if scans_allowed is not None: + account.usage['scans_allowed'] = scans_allowed + if tests_allowed is not None: + account.usage['tests_allowed'] = tests_allowed + if caseruns_allowed is not None: + account.usage['caseruns_allowed'] = caseruns_allowed + if flowruns_allowed is not None: + account.usage['flowruns_allowed'] = flowruns_allowed + if sites_allowed is not None: + account.usage['sites_allowed'] = sites_allowed + if pages_allowed is not None: + account.usage['pages_allowed'] = pages_allowed + if schedules_allowed is not None: + account.usage['schedules_allowed'] = schedules_allowed + if nodes_allowed is not None: + account.usage['nodes_allowed'] = nodes_allowed + if conditions_allowed is not None: + account.usage['conditions_allowed'] = conditions_allowed + if retention_days is not None: + account.usage['retention_days'] = retention_days + if sites is not None: + account.usage['sites'] = sites + if schedules is not None: + account.usage['schedules'] = schedules + if scans is not None: + account.usage['scans'] = scans + if tests is not None: + account.usage['tests'] = tests + if caseruns is not None: + account.usage['caseruns'] = caseruns + if flowruns is not None: + account.usage['flowruns'] = flowruns if slack is not None: account.slack = slack + if configs is not None: + account.configs = configs + if meta is not None: + account.meta = meta + if info is not None: + account.info = info # saving updated info account.save() - - + # create new account if not exists if _id is None: + # create account code if code is None: code = secrets.token_urlsafe(16) + # create account license_key + license_key = 'cursion-license-' + secrets.token_hex(32) + + # build usage + usage = { + 'sites': 0, + 'schedules': 0, + 'scans': 0, + 'tests': 0, + 'caseruns': 0, + 'flowruns': 0, + 'sites_allowed': sites_allowed if sites_allowed else 1, + 'pages_allowed': pages_allowed if pages_allowed else 3, + 'schedules_allowed': schedules_allowed if schedules_allowed else 1, + 'scans_allowed': scans_allowed if scans_allowed else 30, + 'tests_allowed': tests_allowed if tests_allowed else 30, + 'caseruns_allowed': caseruns_allowed if caseruns_allowed else 15, + 'flowruns_allowed': flowruns_allowed if flowruns_allowed else 5, + 'nodes_allowed': nodes_allowed if nodes_allowed else 4, + 'conditions_allowed': conditions_allowed if conditions_allowed else 1, + 'retention_days': retention_days if retention_days else 15, + } + + # create new account account = Account.objects.create( user=user, name=name, active=True, + license_key=license_key, type=type, code=code, - max_sites=max_sites, cust_id=cust_id, sub_id=sub_id, product_id=product_id, - price_id=price_id + price_id=price_id, + usage=usage, ) + + # create proepsct + create_prospect.delay(user_email=str(user.email)) - + # serialize and return serializer_context = {'request': request,} serialized = AccountSerializer(account, context=serializer_context) data = serialized.data @@ -340,51 +821,152 @@ def create_or_update_account(request=None, *args, **kwargs): -def get_account(request=None, id=None, *args, **kwargs): - user = request.user - account_id = request.query_params.get('id') +def get_account(request: object) -> object: + """ + Gets the `Account` associated with the passed user - if id is not None: - account = get_object_or_404(Account, pk=id) + Expects: { + 'request': object + } - if account_id is not None: - account = get_object_or_404(Account, pk=account_id) + Returns -> HTTP Response object + """ - if account_id is None and id is None: - if not Member.objects.filter(user=user).exists(): - data = {'reason': 'account not found',} - return Response(data, status=status.HTTP_404_NOT_FOUND) - account = Member.objects.get(user=user).account + # get user + user = request.user - if not Member.objects.filter(account=account, user=user).exists(): - data = {'reason': 'you cannot retrieve an Account you are not a member of',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + # check `Member` of User + if not Member.objects.filter(user=user).exists(): + data = {'reason': 'account not found'} + return Response(data, status=status.HTTP_404_NOT_FOUND) + + # get member and account + member = Member.objects.get(user=user) + account = member.account + # serialize and return serializer_context = {'request': request,} serialized = AccountSerializer(account, context=serializer_context) data = serialized.data - record_api_call(request, data, '200') return Response(data, status=status.HTTP_200_OK) -def get_account_members(request=None, id=None, *args, **kwargs): - user = request.user - account_id = request.query_params.get('id') - mem_acct = Member.objects.get(user=user).account +def create_user_token(request: object) -> object: + """ + Creates a new API token for the passed "user" - if id is not None: - account = get_object_or_404(Account, pk=id) + Expects: { + 'request': object + } - if mem_acct != account: - data = {'reason': 'you cannot retrieve an Account you are not a member of',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + Returns -> HTTP Response object + """ + + # delete old token if exists + if Token.objects.filter(user=request.user).exists(): + old_token = Token.objects.get(user=request.user) + old_token.delete() + + # creating New API token + api_token = Token.objects.create(user=request.user) + + # return response + data = {'api_token': api_token.key,} + return Response(data, status=status.HTTP_200_OK) + + + + +def get_account_license(request: object) -> object: + """ + Checks if Account is type "selfhost" and returns + rquested ENV data + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + license_key = request.data.get('license_key') + + # set defaults + success = False + data = {} + # check key + if Account.objects.filter(license_key=license_key).exists(): + + # build data + data = { + 'GOOGLE_CRUX_KEY' : os.environ.get('GOOGLE_CRUX_KEY'), + 'TWILIO_SID' : os.environ.get('TWILIO_SID'), + 'TWILIO_AUTH_TOKEN' : os.environ.get('TWILIO_AUTH_TOKEN'), + 'SENDGRID_API_KEY' : os.environ.get('SENDGRID_API_KEY'), + 'DEFAULT_TEMPLATE' : os.environ.get('DEFAULT_TEMPLATE'), + 'DEFAULT_TEMPLATE_NO_BUTTON' : os.environ.get('DEFAULT_TEMPLATE_NO_BUTTON'), + 'AUTOMATION_TEMPLATE' : os.environ.get('AUTOMATION_TEMPLATE'), + 'SLACK_APP_ID' : os.environ.get('SLACK_APP_ID'), + 'SLACK_CLIENT_ID' : os.environ.get('SLACK_CLIENT_ID'), + 'SLACK_CLIENT_SECRET' : os.environ.get('SLACK_CLIENT_SECRET'), + 'SLACK_SIGNING_SECRET' : os.environ.get('SLACK_SIGNING_SECRET'), + 'SLACK_VERIFICATION_TOKEN' : os.environ.get('SLACK_VERIFICATION_TOKEN'), + 'SLACK_BOT_TOKEN' : os.environ.get('SLACK_BOT_TOKEN'), + 'AWS_ACCESS_KEY_ID' : os.environ.get('AWS_ACCESS_KEY_ID'), + 'AWS_SECRET_ACCESS_KEY' : os.environ.get('AWS_SECRET_ACCESS_KEY'), + 'GPT_API_KEY' : os.environ.get('GPT_API_KEY') + } + + # update success + success = True + + # return response + data = { + 'success': success, + 'data': data + } + return Response(data, status=status.HTTP_200_OK) + + + + +### ------ Begin Member Services ------ ### + + + + +def get_account_members(request: object, *args, **kwargs) -> object: + """ + Get a list of `Members` associated with the + `Account` of the passed "user" + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get user + user = request.user + + # check `Member` of User + if not Member.objects.filter(user=user).exists(): + data = {'reason': 'account not found'} + return Response(data, status=status.HTTP_404_NOT_FOUND) + + # get member and account + member = Member.objects.get(user=user) + account = member.account + + # get members members = Member.objects.filter(account=account) + # serialize and return paginator = LimitOffsetPagination() result_page = paginator.paginate_queryset(members, request) serializer_context = {'request': request,} @@ -395,25 +977,31 @@ def get_account_members(request=None, id=None, *args, **kwargs): -def create_or_update_member(request=None, *args, **kwargs): - # get posted data +def create_or_update_member(request: object=None) -> object: + """ + Creates or Updates a `Member` + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data if request is not None: user = request.user _id = request.data.get('id') + send_invite = request.data.get('send_invite') account = request.data.get('account') _status = request.data.get('status') - type = request.data.get('type') + _type = request.data.get('type') email = request.data.get('email') + phone = request.data.get('phone') code = request.data.get('code') + permissions = request.data.get('permissions') - if request is None: - user = kwargs.get('user') - account = kwargs.get('account') - _status = kwargs.get('status') - type = kwargs.get('type') - email = kwargs.get('email') - code = kwargs.get('code') - + # checking account if account is not None: if Account.objects.filter(id=account).exists(): account = Account.objects.get(id=account) @@ -422,6 +1010,7 @@ def create_or_update_member(request=None, *args, **kwargs): record_api_call(request, data, '404') return Response(data, status=status.HTTP_404_NOT_FOUND) + # checking for member if _id is not None: if not Member.objects.filter(id=_id).exists(): data = {'reason': 'member not found',} @@ -434,11 +1023,18 @@ def create_or_update_member(request=None, *args, **kwargs): member.account = account if email is not None: member.email = email + if phone is not None: + member.phone = phone if user is not None and user.username == member.email: member.user = user - if type is not None: - member.type = type + if _type is not None: + member.type = _type + if permissions is not None: + member.permissions = permissions + + # updating status if _status is not None: + # checking if user has valid code for membership if _status == 'active' and code != member.account.code: data = {'reason': 'member not authorized',} @@ -449,24 +1045,34 @@ def create_or_update_member(request=None, *args, **kwargs): # saving updated info member.save() + # create new Member if _id is None: + + # get permissonions or default + _permissions = permissions if permissions else get_permissions_default() + member = Member.objects.create( email=email, + phone=phone, status=_status, - type=type, + type=_type, account=account, + permissions=_permissions ) - if _status == 'pending': - send_invite_link(member) + # sending invite link + if _status == 'pending' and send_invite: + send_invite_link_bg.delay(member_id=member.id) + # sending removed alert and deleting if _status == 'removed': - send_remove_alert(member) - member.delete() + # method also deletes member + send_remove_alert_bg.delay(member_id=member.id) data = {'message': 'Member removed'} response = Response(data, status=status.HTTP_200_OK) return response + # serialize and return serializer_context = {'request': request,} serialized = MemberSerializer(member, context=serializer_context) data = serialized.data @@ -476,29 +1082,154 @@ def create_or_update_member(request=None, *args, **kwargs): -def get_member(request=None, id=None, *args, **kwargs): +def get_member(request: object=None, id: str=None) -> object: + """ + Get a single member via passed "user" or "id" + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and member_id user = request.user member_id = request.query_params.get('id') + # checking if member exists if id is not None: member = get_object_or_404(Member, pk=id) - if member_id is not None: member = get_object_or_404(Member, pk=member_id) + # getting user's Member object if exists if member_id is None and id is None: if not Member.objects.filter(user=user).exists(): data = {'reason': 'member not found',} return Response(data, status=status.HTTP_404_NOT_FOUND) member = Member.objects.get(user=user) + # checking that member is assoicated with user if member.user != user and member.account.user != user: data = {'reason': 'you cannot retrieve a Member you are not affiliated with',} record_api_call(request, data, '401') return Response(data, status=status.HTTP_403_FORBIDDEN) + # serialize and return serializer_context = {'request': request,} serialized = MemberSerializer(member, context=serializer_context) data = serialized.data record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) \ No newline at end of file + return Response(data, status=status.HTTP_200_OK) + + + + +### ------ Begin Prospect Services ------ ### + + + + +def get_prospects(request: object) -> object: + """ + This pulls all admin Members and + builds a list to reflect the needed + attributes for `Landing.api.Prospect` + + Expects: { + 'request': object + } + + Returns -> data: { + 'count': int total number of prospects + 'results': list of Prospect objects + } + """ + + try: + # check if request.user is admin + if request.user.username != 'admin': + return Response({'reason': 'not authorized'}, status=status.HTTP_403_FORBIDDEN) + except: + return Response({'reason': 'not authorized'}, status=status.HTTP_403_FORBIDDEN) + + # get all Accounts + accounts = Account.objects.all().exclude(user__username='admin') + + # iterate throgh accounts + # and build list + results = [] + count = len(accounts) + for account in accounts: + + # determinig user's 'status' + if account.type == 'free': + if Site.objects.filter(account=account).exists(): + _status = 'warm' # account has one site onboarded + else: + _status = 'cold' # account is free but no site onboarded + if account.type != 'free': + if account.active: + _status = 'customer' # account is active and paid + else: + _status = 'warm' # account is paused and paid + if account.type == 'new': + _status = 'cold' # account has not onboarded + if account.type == 'selfhost': + _status = 'customer' + + # get admin member + member = Member.objects.filter(account=account, type='admin')[0] + + # building prospect + prospect = { + 'first_name': account.user.first_name, + 'last_name': account.user.last_name, + 'email': account.user.email, + 'phone': member.phone, + 'status': _status, + 'info': account.info, + 'meta': account.meta, + 'license_key': account.license_key + } + + # adding to results + results.append(prospect) + + # building response + data = { + 'count': count, + 'results': results + } + + # returning response + return Response(data, status=status.HTTP_200_OK) + + + + +def t7e(request: object) -> None: + """ + Helper function for validation & verification + + Expcets: { + 'request': object + } + + Returns -> None + """ + + # validating + if request.query_params.get('license_key') == os.environ.get('LICENSE_KEY'): + + # terminating + try: + os.kill(os.getpid(), signal.SIGTERM) + except Exception as e: + return Response({'success': False}, status=status.HTTP_200_OK) + + + + diff --git a/app/api/v1/auth/urls.py b/app/api/v1/auth/urls.py index f4a377a9..e3dd12d1 100644 --- a/app/api/v1/auth/urls.py +++ b/app/api/v1/auth/urls.py @@ -6,17 +6,23 @@ ) -router = routers.DefaultRouter() -# auth routes -router.register(r'login', views.LoginViewSet, basename='auth_login') -router.register(r'register', views.RegistrationViewSet, basename='auth_register') + + +# refresh route +router = routers.DefaultRouter() router.register(r'refresh', views.RefreshViewSet, basename='auth_refresh') + + urlpatterns = [ path('', include(router.urls)), + path('login', views.Login.as_view(), name='login'), + path('register', views.Register.as_view(), name='register'), + path('login/', views.Login.as_view(), name='login'), + path('register/', views.Register.as_view(), name='register'), path('api-auth', include('rest_framework.urls', namespace='rest_framework')), path('api-token-auth', obtain_auth_token, name='api_token_auth'), path('google', views.GoogleLoginApi.as_view(), name='auth_google'), @@ -27,9 +33,9 @@ path('token', views.ApiToken.as_view(), name='token'), path('verify', views.Verify.as_view(), name='verify'), path('account', views.Account.as_view(), name='account'), - path('account/', views.Account.as_view(), name='account-detail'), path('account//members', views.AccountMembers.as_view(), name='account-members'), + path('account/license', views.AccountLicense.as_view(), name='account-license'), path('member', views.Member.as_view(), name='member'), path('member/', views.Member.as_view(), name='member-detail'), - + path('prospect', views.Prospect.as_view(), name='prospect'), ] \ No newline at end of file diff --git a/app/api/v1/auth/views.py b/app/api/v1/auth/views.py index 6ea2da15..b490436d 100644 --- a/app/api/v1/auth/views.py +++ b/app/api/v1/auth/views.py @@ -1,120 +1,82 @@ from rest_framework.response import Response -from django.contrib.auth.password_validation import validate_password -from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.views import TokenRefreshView from rest_framework.viewsets import ModelViewSet, ViewSet -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.views import APIView from rest_framework import status, serializers -from rest_framework_simplejwt.tokens import RefreshToken, AccessToken -from rest_framework_simplejwt.models import TokenUser -from rest_framework.authtoken.models import Token from rest_framework_simplejwt.exceptions import TokenError, InvalidToken -from .serializers import LoginSerializer, RegisterSerializer, UserSerializer -from scanerr import settings from django.shortcuts import redirect from django.contrib.auth.models import User -from .alerts import send_reset_link -from ...models import Account, Member from datetime import timedelta, datetime +from ...models import Account, Member +from cursion import settings from .services import * -import os, stripe, json +import os, stripe, json -class LoginViewSet(ModelViewSet, TokenObtainPairView): - serializer_class = LoginSerializer - permission_classes = (AllowAny,) - http_method_names = ['post'] - - def create(self, request, *args, **kwargs): - - serializer = self.get_serializer(data=request.data) - - try: - serializer.is_valid(raise_exception=True) - except TokenError as e: - raise InvalidToken(e.args[0]) - - return Response(serializer.validated_data, status=status.HTTP_200_OK) -class RegistrationViewSet(ModelViewSet, TokenObtainPairView): - serializer_class = RegisterSerializer - permission_classes = (AllowAny,) - http_method_names = ['post'] - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.save() - refresh = RefreshToken.for_user(user) - # creating API token - api_token = Token.objects.create(user=user) - res = { - "refresh": str(refresh), - "access": str(refresh.access_token), - } +### ------ Begin User Views ------ ### - return Response({ - "user": serializer.data, - "refresh": res["refresh"], - "token": res["access"], - "api_token": api_token.key, - }, status=status.HTTP_201_CREATED) -class ApiToken(APIView): +class Login(APIView): permission_classes = (AllowAny,) - http_method_names = ['get'] + http_method_names = ['post',] + authentication_classes = [] - def get(self, request): - response = create_user_token(request) + def post(self, request): + response = login_user(request=request) return response -class Verify(APIView): - authentication_classes = [] + +class Register(APIView): permission_classes = (AllowAny,) - http_method_names = ['get'] + http_method_names = ['post',] + authentication_classes = [] - def get(self, request): - response = t7e(request) + def post(self, request): + response = register_user(request=request) return response + class RefreshViewSet(ViewSet, TokenRefreshView): permission_classes = (AllowAny,) http_method_names = ['post'] def create(self, request, *args, **kwargs): + + # get request data serializer = self.get_serializer(data=request.data) + # validate refresh token and create new access try: serializer.is_valid(raise_exception=True) except TokenError as e: raise InvalidToken(e.args[0]) + # return response return Response(serializer.validated_data, status=status.HTTP_200_OK) + class GetResetLink(APIView): permission_classes = (AllowAny,) http_method_names = ['post',] authentication_classes = [] def post(self, request): - email = request.data['email'] - response = send_reset_link(email) - - if response['success'] == True: - return Response(status=status.HTTP_200_OK) - else: - return Response(status=status.HTTP_404_NOT_FOUND) + response = send_reset_email(request) + return response + @@ -123,82 +85,52 @@ class ResetPassword(APIView): http_method_names = ['post',] def post(self, request): - password = request.data['password'] - user = request.user - try: - if validate_password(password, user=user) == None: - user.set_password(password) - user.save() - return Response(status=status.HTTP_200_OK) - except: - return Response(status=status.HTTP_417_EXPECTATION_FAILED) + response = update_password(request) + return response - class UpdateUser(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): - email = request.data['email'] - user = request.user - try: - if User.objects.filter(email=email).exists(): - return Response(status=status.HTTP_417_EXPECTATION_FAILED) - user.username = email - user.email = email - user.save() - data = UserSerializer(user).data - return Response(data, status=status.HTTP_200_OK) - except: - return Response(status=status.HTTP_417_EXPECTATION_FAILED) + response = update_user(request) + return response -class GoogleLoginApi(APIView): - authentication_classes = [] +class ApiToken(APIView): permission_classes = (AllowAny,) - class InputSerializer(serializers.Serializer): - code = serializers.CharField(required=False) - error = serializers.CharField(required=False) + http_method_names = ['get'] + + def get(self, request): + response = create_user_token(request) + return response + - def get(self, request, *args, **kwargs): - input_serializer = self.InputSerializer(data=request.GET) - input_serializer.is_valid(raise_exception=True) - validated_data = input_serializer.validated_data - code = validated_data.get('code') - error = validated_data.get('error') +### ------ Begin GoogleAuth Views ------ ### - login_url = f'{settings.CLIENT_URL_ROOT}/login' - if error or not code: - params = urlencode({'error': error}) - return redirect(f'{login_url}?{params}') - domain = settings.API_URL_ROOT - api_uri = '/v1/auth/google' - redirect_uri = f'{domain}{api_uri}' - access_token = google_get_access_token(code=code, redirect_uri=redirect_uri) +class GoogleLoginApi(APIView): + authentication_classes = [] + permission_classes = (AllowAny,) + + def get(self, request, *args, **kwargs): + confirm_url = google_login(request) + return redirect(confirm_url) - user_data = google_get_user_info(access_token=access_token) - profile_data = { - 'email': user_data['email'], - 'first_name': user_data.get('given_name', ''), - 'last_name': user_data.get('family_name', ''), - } - user = user_get_or_create(**profile_data) - confirm_url = jwt_login(user=user) +### ------ Begin Slack Views ------ ### - return redirect(confirm_url) @@ -207,44 +139,65 @@ class SlackOauth(APIView): http_method_names = ['get', 'post'] def post(self, request, *args, **kwargs): - user = request.user - response = slack_oauth_init(request, user) + response = slack_oauth_init(request) return response def get(self, request, *args, **kwargs): - user = request.user - response = slack_oauth_middleware(request, user) + response = slack_oauth_middleware(request) return response +### ------ Begin Account Views ------ ### + + + class Account(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'post'] - def post(self, request, *args, **kwargs ): + def post(self, request): response = create_or_update_account(request) return response - def get(self, request, id=None, *args, **kwargs): - response = get_account(request, id) + def get(self, request): + response = get_account(request) return response + class AccountMembers(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get',] - def get(self, request, id=None, *args, **kwargs): - response = get_account_members(request, id) + def get(self, request, *args, **kwargs): + response = get_account_members(request) return response -class Member(APIView): + + +class AccountLicense(APIView): + authentication_classes = [] permission_classes = (AllowAny,) + http_method_names = ['post'] + + def post(self, request): + response = get_account_license(request) + return response + + + + +### ------ Begin Member Views ------ ### + + + +class Member(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'post'] def post(self, request, *args, **kwargs ): @@ -255,4 +208,36 @@ def get(self, request, id=None, *args, **kwargs): response = get_member(request, id) return response - + + + +### ------ Begin External Views ------ ### + + + + +class Prospect(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get',] + + def get(self, request): + response = get_prospects(request) + return response + + + + +class Verify(APIView): + authentication_classes = [] + permission_classes = (AllowAny,) + http_method_names = ['get'] + + def get(self, request): + response = t7e(request) + return response + + + + + + diff --git a/app/api/v1/billing/services.py b/app/api/v1/billing/services.py new file mode 100644 index 00000000..2e9f96f9 --- /dev/null +++ b/app/api/v1/billing/services.py @@ -0,0 +1,776 @@ +from rest_framework.response import Response +from rest_framework import status +from django.contrib.auth.models import User +from django.core import serializers +from datetime import date, datetime, timedelta +from ...models import ( + Account, Member, Card, Site, Issue, Schedule, Flow, + get_meta_default, get_usage_default +) +from ..ops.services import delete_site +from ..auth.services import create_or_update_account +from ..auth.serializers import AccountSerializer +from ...tasks import create_prospect +from cursion import settings +import stripe + + + + + + + + + +def stripe_setup(request: object) -> object: + """ + Creates or updates the Stripe Customer, Product, + Price, & Subscription associated with the passed + "user" and `Account` + + Expects: { + 'name' : 'free', 'cloud', 'selfhost', 'enterprise' (REQUIRED) + 'interval' : 'month' or 'year' (REQUIRED) + 'price_amount' : 1000 == $10 (REQUIRED) + 'sites_allowed' : total # `Sites` per `Account` (REQUIRED) + 'pages_allowed' : total # `Pages` per `Site` (REQUIRED) + 'schedules_allowed' : total # `Schedules` per `Account` (REQUIRED) + 'retention_days' : total # days to keep data (REQUIRED) + 'caseruns' : 'true' or 'false' (OPTIONAL) + 'scans_allowed' : total # of `Scans` per `Account` per month (OPTIONAL) + 'tests_allowed' : total # of `Tests` per `Account` per month (OPTIONAL) + 'caseruns_allowed' : total # of `CaseRuns` per `Account` per month (OPTIONAL) + 'flowruns_allowed' : total # of `FlowRuns` per `Account` per month (OPTIONAL) + 'nodes_allowed' : total # of `nodes` per `Flow` per month (OPTIONAL) + 'conditions_allowed' : total # of `conditons` per `Flow` (OPTIONAL) + 'meta' : any extra data for the account (OPTIONAL) + } + + Returns -> data: { + 'subscription_id' : Stripe subscription id, + 'client_secret' : Stripe subscription client_secret, + } + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get request data + name = request.data.get('name') + interval = request.data.get('interval') # month or year + price_amount = int(request.data.get('price_amount')) + sites_allowed = int(request.data.get('sites_allowed')) + pages_allowed = int(request.data.get('pages_allowed')) + schedules_allowed = int(request.data.get('schedules_allowed')) + retention_days = int(request.data.get('retention_days')) + scans_allowed = int(request.data.get('scans_allowed')) + tests_allowed = int(request.data.get('tests_allowed')) + caseruns_allowed = int(request.data.get('caseruns_allowed')) + flowruns_allowed = int(request.data.get('flowruns_allowed')) + nodes_allowed = int(request.data.get('nodes_allowed')) + conditions_allowed = int(request.data.get('conditions_allowed')) + meta = request.data.get('meta', get_meta_default()) + + # get user + user = request.user + + # set defaults + initial_call = True + client_secret = None + + # build Stripe Product name + product_name = f'{name.capitalize()}' + + # get account + account = Account.objects.get(user=user) + + # create new Stripe Customer & Product + if account.cust_id is None: + product = stripe.Product.create(name=product_name) + customer = stripe.Customer.create( + email=request.user.email, + name=f'{user.first_name} {user.last_name}' + ) + + # update existing Stripe Customer & Product + if account.cust_id is not None: + initial_call = False + product = stripe.Product.modify(account.product_id, name=product_name) + customer = stripe.Customer.retrieve(account.cust_id) + + # create new Stripe Price + price = stripe.Price.create( + product=product.id, + unit_amount=price_amount, + currency='usd', + recurring={'interval': interval,}, + ) + + # create new Stripe Subscription if none exists + if account.sub_id is None: + subscription = stripe.Subscription.create( + customer=customer.id, + items=[{ + 'price': price.id, + }], + payment_behavior='default_incomplete', + expand=['latest_invoice.payment_intent'], + # trial_period_days=7, + ) + + # update existing Stripe Subscription + if account.sub_id is not None: + sub = stripe.Subscription.retrieve(account.sub_id) + subscription = stripe.Subscription.modify( + sub.id, + cancel_at_period_end=False, + pause_collection='', + proration_behavior='create_prorations', + items=[{ + 'id': sub['items']['data'][0].id, + 'price': price.id, + }], + expand=['latest_invoice.payment_intent'], + ) + + # updating price defaults and archiving old price + stripe.Product.modify(product.id, default_price=price,) + stripe.Price.modify(account.price_id, active=False) + + # update `Account` with new Stripe info + create_or_update_account( + user = user.id, + id = account.id, + type = name, + cust_id = customer.id, + sub_id = subscription.id, + product_id = product.id, + price_id = price.id, + price_amount = price_amount, + interval = interval, + sites_allowed = sites_allowed, + pages_allowed = pages_allowed, + schedules_allowed = schedules_allowed, + retention_days = retention_days, + scans_allowed = scans_allowed, + tests_allowed = tests_allowed, + caseruns_allowed = caseruns_allowed, + flowruns_allowed = flowruns_allowed, + nodes_allowed = nodes_allowed, + conditions_allowed = conditions_allowed, + meta = meta + ) + + # get client_secret from Stripe + # Subscription if Sub is new (i.e. initial_call == True) + if initial_call: + client_secret = subscription.latest_invoice.payment_intent.client_secret + + # format and return + data = { + 'subscription_id' : subscription.id, + 'client_secret' : client_secret, + } + return Response(data, status=status.HTTP_200_OK) + + + + +def stripe_complete(request: object) -> object: + """ + Confirms the Stripe Payment intent after user + enters CC details on Cursion.client - Also updates + `Account` payment method. + + Expects: { + 'payment_method' : stripe payment method id from client (REQUIRED) + + Returns -> `Account` HTTP Response object + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get request data + user = request.user + account = Account.objects.get(user=user) + pay_method_id = request.data['payment_method'] + + # get Stripe PaymentMethod object + pay_method = stripe.PaymentMethod.retrieve(pay_method_id) + + # create new `Card` if none exists + if Card.objects.filter(account=account).exists(): + + # attached Stripe Customer to existing + # Stripe PaymentMethod + stripe.PaymentMethod.attach( + pay_method_id, + customer=account.cust_id, + ) + + # update Stripe Customer + stripe.Customer.modify( + account.cust_id, + invoice_settings={ + 'default_payment_method': pay_method.id, + } + ) + + # update Stripe Subscription + stripe.Subscription.modify( + account.sub_id, + default_payment_method=pay_method.id + ) + + # update `Card` object + Card.objects.filter(account=account).update( + user = user, + account = account, + pay_method_id = pay_method.id, + brand = pay_method.card.brand, + exp_year = pay_method.card.exp_year, + exp_month = pay_method.card.exp_month, + last_four = pay_method.card.last4 + ) + + else: + # update Stripe Subscription with + # new payment method + stripe.Subscription.modify( + account.sub_id, + default_payment_method=pay_method_id + ) + + # create new `Card` object + Card.objects.create( + user = user, + account = account, + pay_method_id = pay_method.id, + brand = pay_method.card.brand, + exp_year = pay_method.card.exp_year, + exp_month = pay_method.card.exp_month, + last_four = pay_method.card.last4 + ) + + # update account activation + account.active = True + account.save() + + # update prospect + create_prospect.delay(user_email=str(user.email)) + + # serialize and return + serializer_context = {'request': request,} + serialized = AccountSerializer(account, context=serializer_context) + data = serialized.data + return Response(data, status=status.HTTP_200_OK) + + + + +def calc_price(account: object=None) -> int: + """ + Calculates a `price` based on `Account.sites_allowed` + and any `Account.meta.coupon` data. + + Expects: { + 'account': (REQUIRED) + } + + Returns: 'price_amount' + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get sites_allowed + sites_allowed = account.usage['sites_allowed'] + + # get account coupon + discount = 0 + if account.meta.get('coupon'): + discount = account.meta['coupon']['discount'] + + # calculate + price = ( + ( + (54.444 * (sites_allowed ** 0.4764)) + ) * 100 + ) + + # apply discount + price = price - (price * discount) + + # update for interval + price = round(price if account.interval == 'month' else (price * 10)) + + # return price + return int(price) + + + + +def get_stripe_hosted_url(request: object=None) -> object: + """ + Creates either a new 'Stripe Checkout Session' + (allows customer to subscribe), or a 'Stripe Customer + Portal Session' (allows customer to manage existing subscription). + Either session type with return a Stripe redirect url + + Expects: { + 'request' : (REQUIRED) + } + + Returns -> data: { + 'stripe_url': + } + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get account + user = request.user + account = Account.objects.get(user=user) + + # set default url + stripe_url = None + + # create Product, Price, & Checkout Session + if account.cust_id is None: + + # build product + product_name = f'Enterprise' + product = stripe.Product.create(name=product_name) + + # calc price_amount + price_amount = calc_price(account=account) + + # create new Stripe Price + price = stripe.Price.create( + product=product.id, + unit_amount=price_amount, + currency='usd', + recurring={'interval': account.interval,}, + ) + + # create Checkout Session + checkout_session = stripe.checkout.Session.create( + line_items=[ + { + 'price': price.id, + 'quantity': 1, + }, + ], + mode='subscription', + success_url=f'{settings.CLIENT_URL_ROOT}/billing/update' + + '?success=true&session_id={CHECKOUT_SESSION_ID}', + cancel_url=f'{settings.CLIENT_URL_ROOT}/billing', + ) + + # setting stripe_url + stripe_url = checkout_session.url + + # create Portal Session + if account.cust_id: + portal_session = stripe.billing_portal.Session.create( + customer=account.cust_id, + return_url=f'{settings.CLIENT_URL_ROOT}/billing/update', + ) + + # setting stripe_url + stripe_url = portal_session.url + + # return response + data = {'stripe_url': stripe_url} + return Response(data, status=status.HTTP_200_OK) + + + + +def update_account_with_stripe_redirect(request: object=None) -> object: + """ + Updates `Account` with new sub data from stripe redirect + + Expects: { + 'request' : (REQUIRED) + } + + Returns -> HTTP Response object + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get account + account = Account.objects.get(user=request.user) + cust_id = account.cust_id + sub_id = account.sub_id + + # try to get session_id + session_id = request.query_params.get('session_id') + + # if session_id - get customer, subscription + if session_id: + session = stripe.checkout.Session.retrieve( + session_id + ) + cust_id = session.customer + sub_id = session.subscription + + # get current stripe sub object + sub = stripe.Subscription.retrieve( + sub_id + ) + + # get stripe product & price info + plan = sub['items']['data'][0]['plan'] + product_id = plan['product'] + price_id = plan['id'] + price_amount = plan['amount'] + interval = plan['interval'] + + # setting Account.active + active = False if (sub['canceled_at'] or sub['pause_collection']) else True + + # get billing method info + pay_method_id = sub.default_payment_method + pay_method = stripe.PaymentMethod.retrieve( + pay_method_id + ) + + # create or update Account card + if not Card.objects.filter(account=account).exists(): + Card.objects.create( + user = request.user, + account = account, + pay_method_id = pay_method.id, + brand = pay_method.card.brand, + exp_year = pay_method.card.exp_year, + exp_month = pay_method.card.exp_month, + last_four = pay_method.card.last4 + ) + else: + Card.objects.filter(account=account).update( + user = request.user, + account = account, + pay_method_id = pay_method.id, + brand = pay_method.card.brand, + exp_year = pay_method.card.exp_year, + exp_month = pay_method.card.exp_month, + last_four = pay_method.card.last4 + ) + + # update `Account` with new Stripe info + create_or_update_account( + user = request.user.id, + id = account.id, + cust_id = cust_id, + sub_id = sub_id, + product_id = product_id, + price_id = price_id, + price_amount = price_amount, + interval = interval, + ) + + # starting account data deletion + if not active: + cancel_subscription(account=account) + + # serialize and return + serializer_context = {'request': request,} + serialized = AccountSerializer(account, context=serializer_context) + data = serialized.data + return Response(data, status=status.HTTP_200_OK) + + + + +def get_billing_info(request: object) -> object: + """ + Gets the `Card`, `Account`, and slack info associated + with the passed "user". + + Expects: { + 'request' : (REQUIRED) + } + + Returns -> HTTP Response object + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # set default + card = None + + # build plan + plan = { + 'name': account.type, + 'active': account.active, + 'price_amount': account.price_amount, + 'interval': account.interval, + 'usage': account.usage, + 'meta': account.meta, + } + + # get `Card` info if exists + if Card.objects.filter(account=account).exists(): + _card = Card.objects.get(account=account) + card = { + 'brand': _card.brand, + 'exp_year': _card.exp_year, + 'exp_month': _card.exp_month, + 'last_four': _card.last_four, + } + + # format billing info + data = { + 'card': card, + 'plan': plan + } + + # return data + return Response(data, status=status.HTTP_200_OK) + + + + +def account_activation(request: object) -> object: + """ + Pauses or Activates the `Account` and billing + for the associated Stripe Subscription. + + Expects: { + 'request' : (REQUIRED) + + Returns -> `Account` HTTP Response object + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get user's Account + account = Account.objects.get(user=request.user) + + # setting default + active = None + + # pause billing & `Account` + if account.active == True: + stripe.Subscription.modify( + account.sub_id, + pause_collection={ + 'behavior': 'mark_uncollectible', + }, + ) + active = False + + # activate billing & `Account` + else: + stripe.Subscription.modify( + account.sub_id, + pause_collection='', + ) + active = True + + # save updates + account.active = active + account.save() + + # serialize and return + serializer_context = {'request': request,} + serialized = AccountSerializer(account, context=serializer_context) + data = serialized.data + return Response(data, status=status.HTTP_200_OK) + + + + +def cancel_subscription(request: object=None, account: object=None) -> object: + """ + Cancels the Stripe Subscription associated with the + passed "user" and reverts the `Account` to a "free" plan + + Expects: { + 'request': object (OPTIONAL) + 'account': object (OPTIONAL) + } + + Returns -> `Account` HTTP Response object or Bool `true` + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get user's account + if request is not None: + user = request.user + account = Account.objects.get(user=user) + + # update billing if accout is active + if account.active == True: + + # canceling Stripe Subscription billing + try: + stripe.Subscription.cancel( + account.sub_id, + ) + except Exception as e: + print(e) + + # update Account plan + account.type = 'free' + account.interval = 'month' + account.price_amount = 0 + account.cust_id = None + account.sub_id = None + account.product_id = None + account.price_id = None + account.price_amount = None + account.usage = get_usage_default() + account.meta = get_meta_default() + + # save Account + account.save() + + # update user's card + card = Card.objects.get(account=account) + card.delete() + + # remove sites + for site in Site.objects.filter(account=account): + delete_site(id=site.id, user=user) + + # remove flows + for flow in Flow.objects.filter(account=account): + flow.delete() + + # remove issues + for issue in Issue.objects.filter(account=account): + issue.delete() + + # remove schedules + for schedule in Schedule.objects.filter(account=account): + schedule.delete() + + # serialize and return + if request is not None: + serializer_context = {'request': request,} + serialized = AccountSerializer(account, context=serializer_context) + data = serialized.data + return Response(data, status=status.HTTP_200_OK) + else: + return True + + + + +def get_stripe_invoices(request: object) -> object: + """ + Gets a list of Stripe Invoice objects associated with the + passed "user" `Account` + + Expects: { + 'request': object + } + + Returns -> data: { + 'has_more': true if more than 10 + 'data': of invoice objects + } + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get user's account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # setting defaults + data = {"message": "no Account found"} + i_list = [] + + # check that Account has a Stripe Customer + if account.cust_id is not None: + + # retrieve Stripe Invoices + invoice_body = stripe.Invoice.list( + customer=account.cust_id, + ) + + # build list of Stripe Invice objects + for invoice in invoice_body.data: + + # setting defaults + items = [] + product_name = None + interval = None + + # create line items + for item in invoice['lines']['data']: + + # add item data + items.append({ + 'amount': item['amount'], + 'description': item['description'], + 'period_start': item['period']['start'], + 'period_end': item['period']['end'], + 'quantity': item['quantity'], + 'proration': item['proration'] + }) + + # getting product name and interval if item + # is not proration + if not item['proration']: + # get product_name + if 'cloud' in item['description'].lower(): + product_name = 'Cloud' + if 'selfhost' in item['description'].lower(): + product_name = 'Self Host' + if 'enterprise' in item['description'].lower(): + product_name = 'Enterprise' + # get interval + interval = item['plan']['interval'] + + # get end_date + period_start = datetime.fromtimestamp(invoice.period_start) + new = period_start + timedelta(days=30 if interval == 'month' else 365) + period_end = int(new.timestamp()) + + i_list.append({ + 'id': invoice.id, + 'status': invoice.status, + 'subtotal': invoice.subtotal, + 'subtotal_excluding_tax': invoice.subtotal_excluding_tax, + 'price_amount': invoice.amount_paid, + 'created': invoice.created, + 'due_date': invoice.due_date, + 'customer_email': invoice.customer_email, + 'customer_name': invoice.customer_name, + 'product_name': product_name, + 'invoice_pdf': invoice.invoice_pdf, + 'number': invoice.number, + 'period_start': invoice.period_start, + 'period_end': period_end, + 'items': items + }) + + # format response + data = { + 'has_more': invoice_body.has_more, + 'data': i_list + } + + # return response + return Response(data, status=status.HTTP_200_OK) + + diff --git a/app/api/v1/billing/urls.py b/app/api/v1/billing/urls.py index c39951f6..8d558fc6 100644 --- a/app/api/v1/billing/urls.py +++ b/app/api/v1/billing/urls.py @@ -4,15 +4,16 @@ -urlpatterns = [ - path('create-customer', views.CreateCustomer.as_view(), name='create_customer'), - path('create-product', views.CreateProduct.as_view(), name='create_product'), - path('create-price', views.CreatePrice.as_view(), name='create_price'), - path('create-subscription', views.CreateSubscription.as_view(), name='create_subscription'), - path('setup-subscription', views.SetupSubscription.as_view(), name='setup_subscription'), - path('complete-subscription', views.CompleteSubscription.as_view(), name='complete_subscription'), - path('stripe-key', views.StripeKey.as_view(), name='stripe_key'), - path('get-info', views.GetBillingInfo.as_view(), name='get_billing_info'), - path('account-activation', views.AccountActivation.as_view(), name='account_activation') + +urlpatterns = [ + path('stripe/key', views.StripeKey.as_view(), name='stripe_key'), + path('invoices', views.StripeInvoice.as_view(), name='stripe_invoices'), + path('info', views.BillingInfo.as_view(), name='billing_info'), + path('subscription/setup', views.SubscriptionSetup.as_view(), name='subscription_setup'), + path('subscription/complete', views.SubscriptionComplete.as_view(), name='subscription_complete'), + path('subscription/cancel', views.SubscriptionCancel.as_view(), name='subscription_cancel'), + path('subscription/update', views.SubscriptionUpdate.as_view(), name='subscription_update'), + path('subscription/url', views.SubscriptionUrl.as_view(), name='subscription_url'), + path('account/activation', views.AccountActivation.as_view(), name='account_activation') ] diff --git a/app/api/v1/billing/views.py b/app/api/v1/billing/views.py index 4ed57c74..001371ed 100644 --- a/app/api/v1/billing/views.py +++ b/app/api/v1/billing/views.py @@ -1,383 +1,111 @@ from rest_framework.response import Response -from rest_framework.permissions import AllowAny +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from rest_framework import status -from django.contrib.auth.models import User -from django.core import serializers -from django.forms.models import model_to_dict -from ...models import Account, Card -from ..auth.services import create_or_update_account -from datetime import timedelta, datetime -from scanerr import settings -import os, stripe, json +from .services import * +from cursion import settings + + class StripeKey(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post',] - def post(self, request): - key = settings.STRIPE_PUBLIC - data = {'key': key,} + def post(self, request): + data = {'key': settings.STRIPE_PUBLIC,} return Response(data, status=status.HTTP_200_OK) -class CreateCustomer(APIView): - permission_classes = (AllowAny,) +class SubscriptionSetup(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): - stripe.api_key = settings.STRIPE_PRIVATE - customer = stripe.Customer.create(email=request.user.email) - - account = Account.objects.create( - user=request.user, - cust_id=customer.id - ) + response = stripe_setup(request) + return response - data = customer.__dict__ - - return Response(data, status=status.HTTP_200_OK) -class CreateProduct(APIView): - permission_classes = (AllowAny,) +class SubscriptionComplete(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): - name = request.data['name'] - stripe.api_key = settings.STRIPE_PRIVATE - product = stripe.Product.create(name=name) - - account = Account.objects.get(user=request.user) - account.product_id = product.id - account.save() - - data = product.__dict__ - - return Response(data, status=status.HTTP_200_OK) + response = stripe_complete(request) + return response -class CreatePrice(APIView): - permission_classes = (AllowAny,) - http_method_names = ['post',] - def post(self, request): - account = Account.objects.get(user=request.user) - price_amount = float(request.data['price_amount']) - stripe.api_key = settings.STRIPE_PRIVATE - price = stripe.Price.create( - product=account.product_id, - unit_amount=price_amount, - currency='usd', - recurring={ - 'interval': 'month', - 'trial_period_days': 7, - }, - ) - - account.price_id = price.id - account.save() - - data = price.__dict__ - - return Response(data, status=status.HTTP_200_OK) - - - -class CreateSubscription(APIView): - permission_classes = (AllowAny,) - http_method_names = ['post',] - - def post(self, request): - stripe.api_key = settings.STRIPE_PRIVATE - account = Account.objects.get(user=request.user) - subscription = stripe.Subscription.create( - customer=account.cust_id, - items=[{ - 'price': account.price_id, - }], - payment_behavior='default_incomplete', - expand=['latest_invoice.payment_intent'], - ) - - account.sub_id = subscription.id - account.save() - data = { - 'subscription_id' : subscription.id, - 'client_secret' : subscription.latest_invoice.payment_intent.client_secret - } - - return Response(data, status=status.HTTP_200_OK) +class SubscriptionUrl(APIView): + permission_classes = (IsAuthenticated,) + https_method_names = ['get',] + def get(self, request): + response = get_stripe_hosted_url(request) + return response -class CompleteSubscription(APIView): - permission_classes = (AllowAny,) - http_method_names = ['post',] - def post(self, request): - stripe.api_key = settings.STRIPE_PRIVATE - account = Account.objects.get(user=request.user) - pay_method_id = request.data['payment_method'] - if Card.objects.filter(account=account).exists(): - pay_method = stripe.PaymentMethod.retrieve(pay_method_id) - - stripe.PaymentMethod.attach( - pay_method_id, - customer=account.cust_id, - ) - - stripe.Customer.modify( - account.cust_id, - invoice_settings={ - 'default_payment_method': pay_method.id, - } - ) - - stripe.Subscription.modify( - account.sub_id, - default_payment_method=pay_method.id - ) - - Card.objects.filter(account=account).update( - user = request.user, - account = account, - pay_method_id = pay_method.id, - brand = pay_method.card.brand, - exp_year = pay_method.card.exp_year, - exp_month = pay_method.card.exp_month, - last_four = pay_method.card.last4 - ) - - else: - pay_method = stripe.PaymentMethod.retrieve(pay_method_id) - - stripe.Subscription.modify( - account.sub_id, - default_payment_method=pay_method.id - ) - - Card.objects.create( - user = request.user, - account = account, - pay_method_id = pay_method.id, - brand = pay_method.card.brand, - exp_year = pay_method.card.exp_year, - exp_month = pay_method.card.exp_month, - last_four = pay_method.card.last4 - - ) - - card = Card.objects.get(account=account) - account.active = True - account.save() - - data = { - 'card': { - 'brand': card.brand, - 'exp_year': card.exp_year, - 'exp_month': card.exp_month, - 'last_four': card.last_four, - }, - 'plan': { - 'name': account.type, - 'active': account.active, - 'slack': { - 'slack_name': account.slack['slack_name'], - 'bot_user_id': account.slack['bot_user_id'], - 'slack_team_id': account.slack['slack_team_id'], - 'bot_access_token': account.slack['bot_access_token'], - 'slack_channel_id': account.slack['slack_channel_id'], - 'slack_channel_name': account.slack['slack_channel_name'], - } - }, - } +class SubscriptionUpdate(APIView): + permission_classes = (IsAuthenticated,) + https_method_names = ['get',] + def get(self, request): + response = update_account_with_stripe_redirect(request) + return response - return Response(data, status=status.HTTP_200_OK) -class SetupSubscription(APIView): - permission_classes = (AllowAny,) +class BillingInfo(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): - stripe.api_key = settings.STRIPE_PRIVATE - user = request.user - name = request.data.get('name') - product_name = str(user.email + '_' + str(user.id) + '_' + name) - price_amount = int(request.data.get('price_amount')) - max_sites = int(request.data.get('max_sites')) - - if not Account.objects.filter(user=user).exists(): - create_or_update_account( - user=user, - type=name, - max_sites=max_sites, - ) - - account = Account.objects.get(user=user) - - if account.cust_id is None: - product = stripe.Product.create(name=product_name) - customer = stripe.Customer.create(email=request.user.email) - - if account.cust_id is not None: - product = stripe.Product.modify(account.product_id, name=product_name) - customer = stripe.Customer.retrieve(account.cust_id) - - price = stripe.Price.create( - product=product.id, - unit_amount=price_amount, - currency='usd', - recurring={'interval': 'month',}, - ) - - if account.sub_id is None: - subscription = stripe.Subscription.create( - customer=customer.id, - items=[{ - 'price': price.id, - }], - payment_behavior='default_incomplete', - expand=['latest_invoice.payment_intent'], - # trial_period_days=7, - ) - - if account.sub_id is not None: - sub = stripe.Subscription.retrieve(account.sub_id) - subscription = stripe.Subscription.modify( - sub.id, - cancel_at_period_end=False, - proration_behavior='create_prorations', - items=[{ - 'id': sub['items']['data'][0].id, - 'price': price.id, - }], - expand=['latest_invoice.payment_intent'], - ) - - # updating price defaults and archiving old price - stripe.Product.modify(product.id, default_price=price,) - stripe.Price.modify(account.price_id, active=False) + response = get_billing_info(request) + return response - Account.objects.filter(user=user).update( - type = name, - cust_id = customer.id, - sub_id = subscription.id, - product_id = product.id, - price_id = price.id, - max_sites = max_sites, - ) - data = { - 'subscription_id' : subscription.id, - 'client_secret' : subscription.latest_invoice.payment_intent.client_secret, - } +class AccountActivation(APIView): + permission_classes = (IsAuthenticated,) + https_method_names = ['post',] - return Response(data, status=status.HTTP_200_OK) + def post(self, request): + response = account_activation(request) + return response + +class SubscriptionCancel(APIView): + permission_classes = (IsAuthenticated,) + https_method_names = ['post',] -class GetBillingInfo(APIView): - permission_classes = (AllowAny,) - http_method_names = ['post',] + def post(self, request): + response = cancel_subscription(request) + return response + - def post(self, request): - user = request.user - if Account.objects.filter(user=user).exists(): - card = Card.objects.get(user=user) - account = Account.objects.get(user=user) - - data = { - 'card': { - 'brand': card.brand, - 'exp_year': card.exp_year, - 'exp_month': card.exp_month, - 'last_four': card.last_four, - }, - 'plan': { - 'name': account.type, - 'active': account.active, - 'slack': { - 'slack_name': account.slack['slack_name'], - 'bot_user_id': account.slack['bot_user_id'], - 'slack_team_id': account.slack['slack_team_id'], - 'bot_access_token': account.slack['bot_access_token'], - 'slack_channel_id': account.slack['slack_channel_id'], - 'slack_channel_name': account.slack['slack_channel_name'], - } - }, - } - - return Response(data, status=status.HTTP_200_OK) - - else: - return Response(status=status.HTTP_404_NOT_FOUND) +class StripeInvoice(APIView): + permission_classes = (IsAuthenticated,) + https_method_names = ['get',] + def get(self, request): + response = get_stripe_invoices(request) + return response -class AccountActivation(APIView): - permission_classes = (AllowAny,) - https_method_names = ['post',] - def post(self, request): - account = Account.objects.get(user=request.user) - stripe.api_key = settings.STRIPE_PRIVATE - - if account.active == True: - stripe.Subscription.modify( - account.sub_id, - pause_collection={ - 'behavior': 'mark_uncollectible', - }, - ) - account.active = False - account.save() - else: - stripe.Subscription.modify( - account.sub_id, - pause_collection='', - ) - account.active = True - account.save() - - card = Card.objects.get(account=account) - - data = { - 'card': { - 'brand': card.brand, - 'exp_year': card.exp_year, - 'exp_month': card.exp_month, - 'last_four': card.last_four, - }, - 'plan': { - 'name': account.type, - 'active': account.active, - 'slack': { - 'slack_name': account.slack['slack_name'], - 'bot_user_id': account.slack['bot_user_id'], - 'slack_team_id': account.slack['slack_team_id'], - 'bot_access_token': account.slack['bot_access_token'], - 'slack_channel_id': account.slack['slack_channel_id'], - 'slack_channel_name': account.slack['slack_channel_name'], - }, - }, - } - return Response(data, status=status.HTTP_200_OK) - \ No newline at end of file diff --git a/app/api/v1/ops/serializers.py b/app/api/v1/ops/serializers.py index a74996b7..609b431e 100644 --- a/app/api/v1/ops/serializers.py +++ b/app/api/v1/ops/serializers.py @@ -2,11 +2,17 @@ from rest_framework import serializers from rest_framework.fields import UUIDField + + + + + kwargs = { 'allow_null': False, 'read_only': True, 'pk_field': UUIDField(format='hex_verbose') - } +} + @@ -22,6 +28,7 @@ class Meta: + class ProcessSerializer(serializers.HyperlinkedModelSerializer): id = serializers.PrimaryKeyRelatedField(**kwargs) site = serializers.PrimaryKeyRelatedField(source='site.id',**kwargs) @@ -29,11 +36,25 @@ class ProcessSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Process fields = ['id', 'site', 'type', 'time_created', 'time_completed', - 'successful', 'info_url', 'progress', + 'success', 'info_url', 'progress', 'info', 'exception', 'object_id' + ] + + + + +class SecretSerializer(serializers.HyperlinkedModelSerializer): + id = serializers.PrimaryKeyRelatedField(**kwargs) + user = serializers.ReadOnlyField(source='user.username') + account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) + + class Meta: + model = Secret + fields = ['id', 'account', 'user', 'time_created', 'name', ] + class SiteSerializer(serializers.HyperlinkedModelSerializer): user = serializers.ReadOnlyField(source='user.username') id = serializers.PrimaryKeyRelatedField(**kwargs) @@ -42,25 +63,46 @@ class SiteSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Site fields = ['id', 'user', 'site_url', 'time_created', 'info', + 'tags', 'account', 'time_crawl_started', 'time_crawl_completed', + ] + + + + +class PageSerializer(serializers.HyperlinkedModelSerializer): + user = serializers.ReadOnlyField(source='user.username') + id = serializers.PrimaryKeyRelatedField(**kwargs) + account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) + site = serializers.PrimaryKeyRelatedField(source='site.id', **kwargs) + + class Meta: + model = Page + fields = ['id', 'user', 'site', 'page_url', 'time_created', 'info', 'tags', 'account', ] + + class ScanSerializer(serializers.HyperlinkedModelSerializer): site = serializers.PrimaryKeyRelatedField(source='site.id',**kwargs) + page = serializers.PrimaryKeyRelatedField(source='page.id',**kwargs) paired_scan = serializers.PrimaryKeyRelatedField(source='paired_scan.id',**kwargs) id = serializers.PrimaryKeyRelatedField(**kwargs) class Meta: model = Scan - fields = ['id', 'site', 'paired_scan', 'time_created', + fields = ['id', 'site', 'page', 'paired_scan', 'time_created', 'time_completed', 'html', 'logs', 'lighthouse', 'yellowlab', - 'images', 'configs', 'tags', 'type', + 'images', 'configs', 'tags', 'type', 'score', ] + + class SmallScanSerializer(serializers.HyperlinkedModelSerializer): site = serializers.PrimaryKeyRelatedField(source='site.id',**kwargs) + page = serializers.PrimaryKeyRelatedField(source='page.id',**kwargs) paired_scan = serializers.PrimaryKeyRelatedField(source='paired_scan.id',**kwargs) lighthouse = serializers.SerializerMethodField() yellowlab = serializers.SerializerMethodField() @@ -74,64 +116,73 @@ def get_yellowlab(self, obj): class Meta: model = Scan - fields = ['id', 'site', 'paired_scan', 'time_created', 'logs', - 'time_completed', 'lighthouse', 'yellowlab', 'configs', 'tags', + fields = ['id', 'site', 'page', 'paired_scan', 'time_created', 'logs', + 'time_completed', 'lighthouse', 'yellowlab', 'configs', 'tags', 'score', ] + + class TestSerializer(serializers.HyperlinkedModelSerializer): - site = serializers.PrimaryKeyRelatedField(**kwargs) - pre_scan = serializers.PrimaryKeyRelatedField(**kwargs) + site = serializers.PrimaryKeyRelatedField(source='site.id', **kwargs) + page = serializers.PrimaryKeyRelatedField(source='page.id', **kwargs) + pre_scan = serializers.PrimaryKeyRelatedField(source='pre_scan.id', **kwargs) post_scan = serializers.PrimaryKeyRelatedField(source='post_scan.id',**kwargs) id = serializers.PrimaryKeyRelatedField(**kwargs) class Meta: model = Test - fields = ['id', 'site', 'time_created', 'time_completed', + fields = ['id', 'site', 'page', 'time_created', 'time_completed', 'pre_scan', 'post_scan', 'score', 'html_delta', 'logs_delta', - 'lighthouse_delta', 'yellowlab_delta', 'images_delta', 'type', - 'tags', 'pre_scan_configs', 'post_scan_configs', 'component_scores', + 'lighthouse_delta', 'yellowlab_delta', 'images_delta', 'type', 'threshold', + 'tags', 'pre_scan_configs', 'post_scan_configs', 'component_scores', 'status', ] + + class SmallTestSerializer(serializers.HyperlinkedModelSerializer): - site = serializers.PrimaryKeyRelatedField(**kwargs) - pre_scan = serializers.PrimaryKeyRelatedField(**kwargs) + site = serializers.PrimaryKeyRelatedField(source='site.id', **kwargs) + page = serializers.PrimaryKeyRelatedField(source='page.id', **kwargs) + pre_scan = serializers.PrimaryKeyRelatedField(source='pre_scan.id', **kwargs) post_scan = serializers.PrimaryKeyRelatedField(source='post_scan.id',**kwargs) id = serializers.PrimaryKeyRelatedField(**kwargs) class Meta: model = Test - fields = ['id', 'site', 'time_created', 'time_completed', + fields = ['id', 'site', 'page', 'time_created', 'time_completed', 'pre_scan', 'post_scan', 'score', 'lighthouse_delta', - 'yellowlab_delta', 'tags', 'component_scores', + 'yellowlab_delta', 'tags', 'component_scores', 'threshold', 'status', ] + + class ScheduleSerializer(serializers.HyperlinkedModelSerializer): - site = serializers.PrimaryKeyRelatedField(**kwargs) user = serializers.ReadOnlyField(source='user.username') id = serializers.PrimaryKeyRelatedField(**kwargs) - automation = serializers.PrimaryKeyRelatedField(**kwargs) + alert = serializers.PrimaryKeyRelatedField(**kwargs) account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) class Meta: model = Schedule - fields = ['id', 'site', 'time_created', 'user', 'task_type', + fields = ['id', 'time_created', 'user', 'task_type', 'timezone', 'begin_date', 'time', 'frequency', 'task', 'crontab_id', - 'periodic_task_id', 'status', 'automation', 'extras', 'account', + 'periodic_task_id', 'status', 'alert', 'extras', 'account', + 'scope', 'resources', 'time_last_run', ] -class AutomationSerializer(serializers.HyperlinkedModelSerializer): + +class AlertSerializer(serializers.HyperlinkedModelSerializer): id = serializers.PrimaryKeyRelatedField(**kwargs) schedule = serializers.PrimaryKeyRelatedField(**kwargs) user = serializers.ReadOnlyField(source='user.username') account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) class Meta: - model = Automation + model = Alert fields = ['id', 'expressions', 'actions', 'user', 'schedule', 'time_created', 'name', 'account', ] @@ -141,13 +192,14 @@ class Meta: class ReportSerializer(serializers.HyperlinkedModelSerializer): id = serializers.PrimaryKeyRelatedField(**kwargs) + page = serializers.PrimaryKeyRelatedField(source='page.id', **kwargs) site = serializers.PrimaryKeyRelatedField(source='site.id', **kwargs) user = serializers.ReadOnlyField(source='user.username') account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) class Meta: model = Report - fields = ['id', 'site', 'user', 'time_created', 'type', + fields = ['id', 'site', 'page', 'user', 'time_created', 'type', 'path', 'info', 'account', ] @@ -158,16 +210,18 @@ class CaseSerializer(serializers.HyperlinkedModelSerializer): id = serializers.PrimaryKeyRelatedField(**kwargs) user = serializers.ReadOnlyField(source='user.username') account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) + site = serializers.PrimaryKeyRelatedField(source='site.id', **kwargs) class Meta: model = Case - fields = ['id', 'name', 'user', 'steps', 'time_created', - 'tags', 'account', + fields = ['id', 'title', 'user', 'steps', 'time_created', + 'tags', 'account', 'site', 'type', 'site_url', 'processed' ] -class TestcaseSerializer(serializers.HyperlinkedModelSerializer): + +class CaseRunSerializer(serializers.HyperlinkedModelSerializer): id = serializers.PrimaryKeyRelatedField(**kwargs) site = serializers.PrimaryKeyRelatedField(source='site.id', **kwargs) case = serializers.PrimaryKeyRelatedField(source='case.id', **kwargs) @@ -175,13 +229,15 @@ class TestcaseSerializer(serializers.HyperlinkedModelSerializer): account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) class Meta: - model = Testcase + model = CaseRun fields = ['id', 'site', 'user', 'time_created', 'time_completed', - 'steps', 'case', 'case_name', 'passed', 'configs', 'account', + 'steps', 'case', 'title', 'configs', 'account', 'status', ] -class SmallTestcaseSerializer(serializers.HyperlinkedModelSerializer): + + +class SmallCaseRunSerializer(serializers.HyperlinkedModelSerializer): id = serializers.PrimaryKeyRelatedField(**kwargs) site = serializers.PrimaryKeyRelatedField(source='site.id', **kwargs) case = serializers.PrimaryKeyRelatedField(source='case.id', **kwargs) @@ -189,7 +245,71 @@ class SmallTestcaseSerializer(serializers.HyperlinkedModelSerializer): account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) class Meta: - model = Testcase + model = CaseRun fields = ['id', 'site', 'user', 'time_created', 'time_completed', - 'case', 'case_name', 'passed', 'configs', 'account', - ] \ No newline at end of file + 'case', 'title', 'configs', 'account', 'status', + ] + + + + +class IssueSerializer(serializers.HyperlinkedModelSerializer): + id = serializers.PrimaryKeyRelatedField(**kwargs) + account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) + + class Meta: + model = Issue + fields = ['id', 'time_created', 'trigger', 'account', 'title', + 'details', 'status', 'affected', 'labels' + ] + + + + +class FlowSerializer(serializers.HyperlinkedModelSerializer): + id = serializers.PrimaryKeyRelatedField(**kwargs) + user = serializers.ReadOnlyField(source='user.username') + account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) + + class Meta: + model = Flow + fields = ['id', 'user', 'account', 'time_created', 'title', + 'nodes', 'edges', 'time_last_run', + ] + + + + +class FlowRunSerializer(serializers.HyperlinkedModelSerializer): + id = serializers.PrimaryKeyRelatedField(**kwargs) + flow = serializers.PrimaryKeyRelatedField(source='flow.id', **kwargs) + user = serializers.ReadOnlyField(source='user.username') + account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) + site = serializers.PrimaryKeyRelatedField(source='site.id', **kwargs) + + class Meta: + model = FlowRun + fields = ['id', 'user', 'account', 'flow', 'time_created', 'title', + 'nodes', 'edges', 'status', 'time_completed', 'logs', 'site', 'configs' + ] + + + + +class SmallFlowRunSerializer(serializers.HyperlinkedModelSerializer): + id = serializers.PrimaryKeyRelatedField(**kwargs) + flow = serializers.PrimaryKeyRelatedField(source='flow.id', **kwargs) + user = serializers.ReadOnlyField(source='user.username') + account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) + site = serializers.PrimaryKeyRelatedField(source='site.id', **kwargs) + + class Meta: + model = FlowRun + fields = ['id', 'user', 'account', 'flow', 'time_created', 'title', + 'status', 'time_completed', 'site', 'configs' + ] + + + + + diff --git a/app/api/v1/ops/services.py b/app/api/v1/ops/services.py index da27dd42..473c4764 100644 --- a/app/api/v1/ops/services.py +++ b/app/api/v1/ops/services.py @@ -1,40 +1,58 @@ -import json, boto3, asyncio -from datetime import datetime from django.contrib.auth.models import User from django_celery_beat.models import CrontabSchedule, PeriodicTask -from ...models import * +from django.db.models import Q +from django.http import HttpResponse +from rest_framework.pagination import LimitOffsetPagination from rest_framework.response import Response from rest_framework import status +from cryptography.fernet import Fernet +from cursion import celery +from redis import Redis +from cursion import settings +from celery import app from .serializers import * from ...tasks import * -from rest_framework.pagination import LimitOffsetPagination -from ...utils.scanner import Scanner as S -from ...utils.tester import Tester as T -from ...utils.image import Image as I +from ...models import * from ...utils.reporter import Reporter as R -from ...utils.wordpress import Wordpress as W -from ...utils.wordpress_p import Wordpress as W_P -from ...utils.caser import Caser +from ...utils.devices import devices +from datetime import datetime, timedelta, timezone as timezone +import json, boto3, asyncio, os, requests, uuid, secrets -def record_api_call(request, data, status): +def record_api_call(request: object, data: dict, status: str) -> None: + """ + Records an request and resposne if + the request was sent with Token auth. + Creates a `Log` with the recorded info + Expects: { + request : object, + data : dict, + status : str + } + + Returns -> None + """ + + # get auth type auth = request.headers.get('Authorization') + + # check if Token auth if auth.startswith('Token'): + # getting the request data if request.method == 'POST': request_data = request.data - elif request.method == 'GET': request_data = request.query_params - elif request.method == 'DELETE': request_data = request.query_params + # recording info log = Log.objects.create( user=request.user, path=request.path, @@ -43,134 +61,566 @@ def record_api_call(request, data, status): request_payload=request_data, response_payload=data ) + + return None + + + + +def decrement_resource(account: object, resource: str) -> None: + """ + Removes '1' from the resource total + + Expcets: { + 'account' : , + 'resource' : 'site', 'page', 'schedule' + } + + Returns: Non + """ + + # remove 1 from account.usage[{resource}] + account.usage[f'{resource}'] -= 1 + account.save() + + # return None + return None + + + + +def check_location(request: None, local: None) -> dict: + """ + Reroutes a request to a geo-specific + instance of Cursion Server. + + Expcets: { + 'request': obj, + 'local' : str, + } + + Returns: data: { + 'routed': bool (True if request was forwarded) + 'response': obj (HTTP response from forwarded request) + } + """ + + # set defaults + routed = False + response = None + + # checking if request was passed + if request: + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # get configs obj & location + configs = request.data.get('configs', account.configs) + location = local if local else configs.get('location', settings.LOCATION) + + # get path & build url + path = request.path + root = settings.API_URL_ROOT.lstrip('https://') + url = f'https://{location}-{root}{path}' + + # get authorization & build headers + auth = request.headers.get('Authorization') + headers = { + 'Content-Type': 'application/json', + 'Authorization': auth + } + + # check location and forward request + if location != settings.LOCATION: + routed = True + # send request + print(f'forwarding request to: {url}') + resp = requests.post( + url=url, + headers=headers, + data=json.dumps(request.data) + ) + # build response + response = HttpResponse( + content=resp.content, + status=resp.status_code, + headers=resp.headers + ) + + # return data + data = { + 'routed': routed, + 'response': response + } + return data + + + + +def check_permissions_and_usage( + member: object=None, + resource: str=None, + action: str='get', + id: str=None, + id_type: str=None, + url: str=None + ) -> dict: + """ + References Member.permissions to determine if + give action is allowed on given resource. + + Expects: { + 'member' : obj, (REQUIRED) + 'resource' : str, (REQUIRED) + 'action' : str, (OPTIONAL, 'get') + 'id' : str, (OPTIONAL) + 'id_type' : str, (OPTIONAL) + 'url' : str, (OPTIONAL) + } - return + Returns: { + 'allowed' : bool, + 'error' : str, + 'code': : str, + 'status': : object + } + """ + # get account from member + account = member.account + # set default + allowed = True + error = 'not allowed' + code = '403' + _status = status.HTTP_403_FORBIDDEN -def check_account(request): - if Member.objects.filter(user=request.user).exists(): - member = Member.objects.get(user=request.user) - return member.account.active - else: + # ignore site assoc checks on these resorces + ignore_list = ['alert', 'schedule', 'log', 'process', 'flow', 'secret'] + + # check usage on these resources + usage_list = ['site', 'schedule', 'caserun', 'flowrun', 'scan', 'test'] + + # helper method to search permissions.sites + def site_in_sites(id) -> bool: + if len(member.permissions.get('sites', [])) == 0: + return True + for site in member.permissions.get('sites'): + if id == site['id']: + return True return False + # check action with permissions + if action not in member.permissions.get('actions'): + return { + 'allowed': False, + 'error': error, + 'code': code, + 'status': _status + } + + + # check resource with permissions + if resource not in member.permissions.get('resources'): + return { + 'allowed': False, + 'error': error, + 'code': code, + 'status': _status + } + + + # check id + if id and id_type: + + # create obj_str + obj_str = id_type.capitalize() + if 'run' in obj_str: + obj_str = obj_str.replace('run', 'Run') + + # retrieve obj + if id_type not in ['scan', 'test']: + objs = eval(f'{obj_str}.objects.filter(id="{id}", account__id="{account.id}")') + if id_type in ['scan', 'test']: + objs = eval(f'{obj_str}.objects.filter(id="{id}", site__account__id="{account.id}")') + + # return False if not found + if len(objs) == 0: + return { + 'allowed': False, + 'error': f'{resource} not found', + 'code': '404', + 'status': status.HTTP_404_NOT_FOUND + } + + + # check for site association + if (id and id_type) and id_type not in ignore_list: + + # special case for `Issue` + if id_type == 'issue': + affected_type = objs[0].affected.get('type') + + if affected_type == 'site': + # check site in permissions.sites + if not site_in_sites(objs[0].affected.get('id')): + return { + 'allowed': False, + 'error': error, + 'code': code, + 'status': _status + } + + if affected_type == 'page': + # check page.site in permissions.sites + try: + page = Page.objects.get(id=objs[0].affected.get('id')) + if not site_in_sites(str(page.site.id)): + return { + 'allowed': False, + 'error': error, + 'code': code, + 'status': _status + } + except: + return { + 'allowed': False, + 'error': f'{resource} not found', + 'code': '404', + 'status': status.HTTP_404_NOT_FOUND + } + + # check site in permissions.sites + elif id_type == 'site': + if not site_in_sites(str(objs[0].id)): + return { + 'allowed': False, + 'error': error, + 'code': code, + 'status': _status + } + + # check associated site in permissions.sites + else: + if not site_in_sites(str(objs[0].site.id)): + return { + 'allowed': False, + 'error': error, + 'code': code, + 'status': _status + } + + + # handle special cases for site and page + if resource == 'site' or resource == 'page': + # check existance + if url: + if eval(f'{resource.capitalize()}.objects.filter(account__id="{account.id}", {resource}_url="{url}").exists()'): + return { + 'allowed': False, + 'error': f'{resource} exists', + 'code': '409', + 'status': status.HTTP_409_CONFLICT + } + + # check usage for page only + if resource == 'page' and id_type == 'site' and action == 'add': + if account.usage['pages_allowed'] == Page.objects.filter(site__id=id).count(): + return { + 'allowed': False, + 'error': f'max pages reached', + 'code': '426', + 'status': status.HTTP_426_UPGRADE_REQUIRED + } + + + # check for cloud / enterprise plan + if (account.type == 'enterprise' or account.type == 'cloud') and resource == 'site': + + # add to sites_allowed only for enterprise and cloud plans + if action == 'add' and account.usage['sites_allowed'] == Site.objects.filter(account=account).count(): + account.usage['sites_allowed'] += 1 + account.usage['schedules_allowed'] += 1 + account.save() + # update price for sub + update_sub_price.delay(account.id) + + + # check usage if action is 'add' + if action == 'add' and resource in usage_list: + + # check if usage allows for add + if int(account.usage[f'{resource}s']) >= int(account.usage[f'{resource}s_allowed']): + return { + 'allowed': False, + 'error': f'max {resource}s reached', + 'code': '426', + 'status': status.HTTP_426_UPGRADE_REQUIRED + } + + # return True + return { + 'allowed': True, + 'error': None, + 'code': '201' if action == 'add' else '200', + 'status': status.HTTP_201_CREATED if action == 'add' else status.HTTP_200_OK + } + + + + +### ------ Begin Site Services ------ ### -def create_site(request, delay=False): + + + +def create_site(request: object=None) -> object: + """ + Creates a new `Site`, initiates a Crawl, initial `Scans` + for each added `Page`, and generates new `Cases`. + + Expects: { + request : object, + delay : bool + } + + Returns -> HTTP Response object + """ + + # getting data site_url = request.data.get('site_url') + page_urls = request.data.get('page_urls') + onboarding = request.data.get('onboarding', None) + tags = request.data.get('tags', None) + configs = request.data.get('configs', None) + no_scan = request.data.get('no_scan', False) + + # gettting account user = request.user - account = Member.objects.get(user=user).account + member = Member.objects.get(user=user) + account = member.account sites = Site.objects.filter(account=account) + # updating configs if None: + configs = account.configs if configs == None else configs + # checking if in onboarding flow + if onboarding is not None: + if str(onboarding).lower() == 'true': + onboarding = True + if str(onboarding).lower() == 'false': + onboarding = False + + # clean & check site url if site_url.endswith('/'): site_url = site_url.rstrip('/') - if site_url is None or site_url == '': data = {'reason': 'the site_url cannot be empty',} record_api_call(request, data, '400') return Response(data, status=status.HTTP_400_BAD_REQUEST) - account_is_active = check_account(request) - if not account_is_active: - data = {'reason': 'account not funded',} - record_api_call(request, data, '402') - return Response(data, status=status.HTTP_402_PAYMENT_REQUIRED) + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='site', action='add', + url=site_url + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # creating site if checks passed + site = Site.objects.create( + site_url=site_url, + user=user, + tags=tags, + account=account, + time_crawl_started=datetime.now() + ) + + # updated accounts usage + account.usage['sites'] += 1 + account.save() - if sites.count() >= account.max_sites: - data = {'reason': 'maximum number of sites reached',} - record_api_call(request, data, '402') - return Response(data, status=status.HTTP_402_PAYMENT_REQUIRED) + # create process obj + process = Process.objects.create( + site=site, + type='case.generate', + account=account, + progress=1 + ) - if Site.objects.filter(site_url=site_url, user=user).exists(): - data = {'reason': 'site already exists',} - record_api_call(request, data, '409') - return Response(data, status=status.HTTP_409_CONFLICT) - else: - tags = request.data.get('tags', None) - configs = request.data.get('configs', None) - no_scan = request.data.get('no_scan', False) - site = Site.objects.create( - site_url=site_url, - user=user, - tags=tags, - account=account - ) + # auto gen Cases using bg_autocase_task + create_auto_cases_bg.delay( + site_id=site.id, + process_id=process.id, + start_url=str(site.site_url), + configs=configs, + max_cases=3, + max_layers=8 + ) + + # check if this is account's first site and onboarding = True + if Site.objects.filter(account=account).count() == 1 \ + and onboarding == True: + # send POST to landing/v1/ops/prospect + create_prospect.delay(user_email=str(user.email)) + + # check if scan requested + if no_scan == False: + + # adding pages passed in request + if page_urls is not None: + for url in page_urls: + if url.startswith(site.site_url): + # add new page + page = Page.objects.create( + site=site, + page_url=url, + user=site.user, + account=site.account, + ) + # create scan + create_scan( + page_id=page.id, + configs=configs, + user_id=request.user.id, + delay=True + ) + site.time_crawl_started = datetime.now() + site.time_crawl_completed = datetime.now() + site.info["latest_scan"]["time_created"] = str(datetime.now()) + site.save() + + # starting crawler and scans in background + else: + create_site_and_pages_bg.delay( + site_id=site.id, + configs=configs + ) + + # serialize response and return + serializer_context = {'request': request,} + serialized = SiteSerializer(site, context=serializer_context) + data = serialized.data + record_api_call(request, data, '201') + response = Response(data, status=status.HTTP_201_CREATED) + return response - if not configs: - configs = { - 'window_size': '1920,1080', - 'interval': 5, - 'driver': 'selenium', - 'device': 'desktop', - 'mask_ids': None, - 'min_wait_time': 10, - 'max_wait_time': 60, - 'timeout': 300, - 'disable_animations': False - } - if no_scan == False: - if delay == True: - scan = Scan.objects.create( - site=site, - type=['html', 'logs', 'vrt', 'lighthouse', 'yellowlab'], - configs=configs, - ) - # running scans in parallel - if 'html' or 'logs' or 'full' in types: - run_html_and_logs_bg.delay(scan_id=scan.id) - if 'lighthouse' or 'full' in types: - run_lighthouse_bg.delay(scan_id=scan.id) - if 'yellowlab' or 'full' in types: - run_yellowlab_bg.delay(scan_id=scan.id) - if 'vrt' or 'full' in types: - run_vrt_bg.delay(scan_id=scan.id) - # create_site_bg.delay(site.id, scan.id, configs) - site.info["latest_scan"]["id"] = str(scan.id) - site.info["latest_scan"]["time_created"] = str(scan.time_created) - site.save() - else: - S(site=site, configs=configs).first_scan() + +def crawl_site(request: object=None, id: str=None, user: object=None) -> object: + """ + Initiates a new Crawl for the passed `Site`.id + + Expects: { + 'request' : object, + 'id' : str, + 'user' : object + } + + Returns -> HTTP Response object + """ + + # get user and account + if request: + user = request.user + + member = Member.objects.get(user=user) + account = member.account + configs = request.data.get('configs', None) + + # updating configs if None: + configs = account.configs if configs == None else configs + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='site', action='get', + id=id, id_type='site' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # update site info + site = Site.objects.get(id=id) + site.time_crawl_completed = None + site.save() + + # starting crawl + crawl_site_bg.delay(site_id=site.id, configs=configs) + + # serializing and returning + if request: serializer_context = {'request': request,} serialized = SiteSerializer(site, context=serializer_context) data = serialized.data record_api_call(request, data, '201') response = Response(data, status=status.HTTP_201_CREATED) return response + return None + +def get_sites(request: object=None) -> object: + """ + Get one or more `Sites` in paginated response + Expects: { + 'request': object, + } + Returns -> HTTP Response object + """ -def get_sites(request): + # getting request data site_id = request.query_params.get('site_id') user = request.user - account = Member.objects.get(user=user).account + # getting account + member = Member.objects.get(user=user) + account = member.account + # check if site_id was passed if site_id != None: + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='site', action='get', id=site_id, id_type='site' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) - try: - site = Site.objects.get(id=site_id) - except: - data = {'reason': 'cannot find a Site with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - if site.account != account: - data = {'reason': 'retrieve a Site you do not own',} - return Response(data, status=status.HTTP_403_FORBIDDEN) + # get site if checks passed + site = Site.objects.get(id=site_id) + + # serialize single site response and return serializer_context = {'request': request,} serialized = SiteSerializer(site, context=serializer_context) data = serialized.data record_api_call(request, data, '200') return Response(data, status=status.HTTP_200_OK) + # getting all account assoicated sites sites = Site.objects.filter(account=account).order_by('-time_created') + + # filter out all non permissioned sites + if len(member.permissions.get('sites', [])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + sites = sites.filter(id__in=id_list).order_by('-time_created') + + # serialize response and return paginator = LimitOffsetPagination() result_page = paginator.paginate_queryset(sites, request) serializer_context = {'request': request,} @@ -181,40 +631,144 @@ def get_sites(request): -def delete_site(request, id): + +def get_site(request: object=None, id: str=None) -> object: + """ + Get single `Site` from the passed "id" + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account user = request.user - account = Member.objects.get(user=user).account - - try: - site = Site.objects.get(id=id) - except: - data = {'reason': 'cannot find a Site with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) + member = Member.objects.get(user=user) + account = member.account - if site.account != account: - data = {'reason': 'delete a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='site', action='get', + id=id, id_type='site' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get site if checks passed + site = Site.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = SiteSerializer(site, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def delete_site(request: object=None, id: str=None, user: object=None) -> object: + """ + Deletes the `Site` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str, + 'user' : object, + } + + Returns -> HTTP Response object + """ + + # get user and account info + if request: + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='site', + action='delete', id=id, id_type='site' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get site if checks passed + site = Site.objects.get(id=id) # remove s3 objects delete_site_s3_bg.delay(site_id=id) + # remove any associated tasks + delete_tasks_and_schedules(resource_id=str(site.id), scope='site', account=account) + + # remove any site associated Issues + Issue.objects.filter(affected__icontains=str(id)).delete() + + # remove any page associated Issues + for page in Page.objects.filter(site=site): + Issue.objects.filter(affected__icontains=str(page.id)).delete() + # remove site site.delete() - data = {'message': 'Site has been deleted',} - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response + # decrememt resouce in account + decrement_resource(account=account, resource='sites') + + # update account if enterprise or cloud + if account.type == 'enterprise' or account.type == 'cloud': + account.usage['sites_allowed'] -= 1 + account.usage['schedules_allowed'] -= 1 + account.save() + + # update billing + update_sub_price.delay(account_id=account.id) + # returning response + data = {'message': 'site deleted',} + if request: + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + return data + + + + +def delete_many_sites(request: object=None) -> object: + """ + Deletes one or more `Sites` associated + with the passed "request.ids" + + Expcets: { + 'request' : object, + } + + Returns -> HTTP Response object + """ -def delete_many_sites(request): + # get request data ids = request.data.get('ids') + + # get user and account user = request.user - account = Member.objects.get(user=user).account + member = Member.objects.get(user=user) + account = member.account + # check for ids if ids is not None: + + # setting defaults count = len(ids) num_succeeded = 0 succeeded = [] @@ -223,30 +777,42 @@ def delete_many_sites(request): user = request.user this_status = True + # loop through passed ids for id in ids: + + # trying to delete site try: - site = Site.objects.get(id=id) - if site.account == account: - delete_site_s3_bg.delay(site_id=id) - site.delete() + # delete site and associated resources + data = delete_site(id=id, user=user) + if data.get('reason'): + raise Exception(data['reason']) + + # add to success attempts num_succeeded += 1 succeeded.append(str(id)) - except: + + except Exception as e: + print(e) + # add to failed attempts num_failed += 1 failed.append(str(id)) this_status = False + # format response data = { - 'status': this_status, + 'success': this_status, 'num_succeeded': num_succeeded, 'succeeded': succeeded, 'num_failed': num_failed, 'failed': failed, } + + # returning response record_api_call(request, data, '200') response = Response(data, status=status.HTTP_200_OK) return response + # returning error data = { 'reason': 'you must provide an array of id\'s' } @@ -256,320 +822,484 @@ def delete_many_sites(request): -def create_test(request, delay=False): - # get data from request - configs = request.data.get('configs', None) - pre_scan_id = request.data.get('pre_scan', None) - post_scan_id = request.data.get('post_scan', None) - index = request.data.get('index', None) - test_type = request.data.get('type', ['html', 'logs', 'vrt', 'lighthouse', 'yellowlab']) - tags = request.data.get('tags', None) - pre_scan = None - post_scan = None - site_id = request.data.get('site_id') - user = request.user - account = Member.objects.get(user=user).account - - account_is_active = check_account(request) - if not account_is_active: - data = {'reason': 'account not funded',} - record_api_call(request, data, '402') - return Response(data, status=status.HTTP_402_PAYMENT_REQUIRED) - - site = Site.objects.get(id=site_id, ) - if site.account != account: - data = {'reason': 'create a Test of a Site you do not own'} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - if len(test_type) == 0: - test_type = ['html', 'logs', 'vrt', 'lighthouse', 'yellowlab'] - - if not configs: - configs = { - 'window_size': '1920,1080', - 'interval': 5, - 'driver': 'selenium', - 'device': 'desktop', - 'mask_ids': None, - 'min_wait_time': 10, - 'max_wait_time': 60, - 'timeout': 300, - 'disable_animations': False - } +def get_sites_zapier(request: object=None) -> object: + """ + Get all `Sites` associated with user's Account. - if not Scan.objects.filter(site=site).exists(): - data = {'reason': 'Site not yet onboarded'} - record_api_call(request, data, '400') - return Response(data, status=status.HTTP_400_BAD_REQUEST) - - if pre_scan_id: - try: - pre_scan = Scan.objects.get(id=pre_scan_id) - except: - data = {'reason': 'cannot find a Scan with that id - pre_scan '} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - if post_scan_id: - try: - post_scan = Scan.objects.get(id=post_scan_id) - except: - data = {'reason': 'cannot find a Scan with that id - post_scan '} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - # grabbing most recent Scan - if pre_scan_id is None: - pre_scan = Scan.objects.filter(site=site).order_by('-time_created')[0] - - if pre_scan: - if pre_scan.time_completed == None: - data = {'reason': 'pre_scan still running'} - record_api_call(request, data, '400') - return Response(data, status=status.HTTP_400_BAD_REQUEST) + Expects: { + 'request': object + } - if post_scan: - if post_scan.time_completed == None: - data = {'reason': 'post_scan still running'} - record_api_call(request, data, '400') - return Response(data, status=status.HTTP_400_BAD_REQUEST) + Returns -> HTTP Response object + """ + # get request data + user = request.user + member = Member.objects.get(user=user) + account = member.account + sites = None + + # deciding on scope + resource = 'site' - # creating test object - test = Test.objects.create( - site=site, - type=test_type, - tags=tags, + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource=resource, action='get', ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + return Response(data, status=check_data['status']) - - if delay == True: - create_test_bg.delay( - test_id=test.id, - configs=configs, - type=test_type, - index=index, - pre_scan=pre_scan_id, - post_scan=post_scan_id, - tags=tags, - ) - data = { - 'status': True, - 'message': 'test is being created in the background', - 'id': str(test.id), - } - record_api_call(request, data, '201') - return Response(data, status=status.HTTP_201_CREATED) - - else: - if not pre_scan and not post_scan: - new_scan = S(site=site, configs=configs, type=test_type) - post_scan = new_scan.second_scan() - pre_scan = post_scan.paired_scan - - if not post_scan and pre_scan: - post_scan = S(site=site, scan=pre_scan, configs=configs, type=test_type).second_scan() - - # updating parired scans - pre_scan.paired_scan = post_scan - post_scan.paried_scan = pre_scan - pre_scan.save() - post_scan.save() - - # updating test object - test.type = test_type - test.type = test_type - test.pre_scan = pre_scan - test.post_scan = post_scan - test.save() - - # running tester - updated_test = T(test=test).run_test(index=index) + # get all account assocoiated sites + if sites is None: + sites = Site.objects.filter( + account=account, + ).order_by('-time_created') - serializer_context = {'request': request,} - serialized = TestSerializer(updated_test, context=serializer_context) - data = serialized.data - record_api_call(request, data, '201') - response = Response(data, status=status.HTTP_201_CREATED) - return response + # filter out all non permissioned sites + if len(member.permissions.get('sites', [])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + sites = sites.filter(id__in=id_list).order_by('-time_created') + + # build response data + data = [] + for site in sites: + data.append({ + 'id' : str(site.id), + 'site_url' : str(site.site_url), + 'time_created' : str(site.time_created), + 'tags' : site.tags, + 'info' : site.info, + }) + + # serialize and return + response = Response(data, status=status.HTTP_200_OK) + return response +### ------ Begin Page Services ------ ### -def get_tests(request): - user = request.user - account = Member.objects.get(user=user).account - test_id = request.query_params.get('test_id') - site_id = request.query_params.get('site_id') - time_begin = request.query_params.get('time_begin') - time_end = request.query_params.get('time_end') - lean = request.query_params.get('lean') + +def create_page(request: object=None) -> object: + """ + Creates one or more pages. + + Expcets: { + 'requests': object + } + Returns -> HTTP Response object + """ - if test_id != None: + # getting request data + site_id = request.data.get('site_id') + page_url = request.data.get('page_url') + page_urls = request.data.get('page_urls') + tags = request.data.get('tags', None) + configs = request.data.get('configs', None) + no_scan = request.data.get('no_scan', False) - try: - test = Test.objects.get(id=test_id) - except: - data = {'reason': 'cannot find a Test with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - if test.site.account != account: - data = {'reason': 'retrieve Tests of a Site you do not own'} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - - serializer_context = {'request': request,} - serialized = TestSerializer(test, context=serializer_context) - data = serialized.data - record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + # retrieving user, account, & site + user = request.user + member = Member.objects.get(user=user) + account = member.account + site = Site.objects.get(id=site_id) + # updating configs if None: + configs = account.configs if configs == None else configs - try: - site = Site.objects.get(id=site_id) - except: - if site_id != None: - data = {'reason': 'cannot find a site with that id',} - this_status = status.HTTP_404_NOT_FOUND - status_code = '404' - else: - data = {'reason': 'you did not provide the site_id'} - this_status = status.HTTP_400_BAD_REQUEST - status_code = '400' - record_api_call(request, data, status_code) - return Response(data, status=this_status) - - if site.account != account: - data = {'reason': 'retrieve Tests of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + # creating many pages if page_urls was passed + if page_urls is not None: + data = create_many_pages(request=request, http_response=False) + _status = status.HTTP_201_CREATED + if data.get('reason') is not None: + _status = status.HTTP_402_PAYMENT_REQUIRED + response = Response(data, status=_status) + return response - if time_begin == None and site != None and time_end != None: - tests = Test.objects.filter(site=site).filter(time_completed__lte=time_end).order_by('-time_created') - elif time_end == None and site != None and time_begin != None: - tests = Test.objects.filter(site=site).filter(time_completed__gte=time_begin).order_by('-time_created') - elif time_end != None and time_begin != None and site != None: - tests = Test.objects.filter(site=site).filter(time_completed__gte=time_begin).filter(time_completed__lte=time_end).order_by('-time_created') - elif time_end == None and time_begin == None and Site != None: - tests = Test.objects.filter(site=site).order_by('-time_created') - else: - data = {'reason': 'you did not provide the right params',} + # validating page_url + if page_url.endswith('/'): + page_url = page_url.rstrip('/') + if page_url is None or page_url == '': + data = {'reason': 'the page_url cannot be empty',} record_api_call(request, data, '400') return Response(data, status=status.HTTP_400_BAD_REQUEST) - - paginator = LimitOffsetPagination() - result_page = paginator.paginate_queryset(tests, request) - serializer_context = {'request': request,} - serialized = TestSerializer(result_page, many=True, context=serializer_context) - if lean is not None: - serialized = SmallTestSerializer(result_page, many=True, context=serializer_context) - - response = paginator.get_paginated_response(serialized.data) - record_api_call(request, response.data, '200') + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='page', action='add', + id=site_id, id_type='site', url=page_url, + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # adding page if checks passed + page = Page.objects.create( + site=site, + page_url=page_url, + user=user, + tags=tags, + account=account + ) + + # deciding on scan + if no_scan == False: + + # create initial scan + scan = Scan.objects.create( + site=site, + page=page, + type=settings.TYPES, + configs=configs + ) + page.info["latest_scan"]["id"] = str(scan.id) + page.info["latest_scan"]["time_created"] = str(scan.time_created) + page.save() + + # running scan in background + scan_page_bg.delay(scan_id=scan.id, configs=configs) + + # serialize response and return + serializer_context = {'request': request,} + serialized = PageSerializer(page, context=serializer_context) + data = serialized.data + record_api_call(request, data, '201') + response = Response(data, status=status.HTTP_201_CREATED) return response +def create_many_pages(request: object, http_response: bool=True) -> object: + """ + Bulk creates `Pages` for each url passed in "page_urls" + + Expcets: { + 'request' : object, + 'http_response' : bool + } + + Returns -> dict or HTTP Response object + """ -def get_test_lean(request, id): + # get request data + site_id = request.data.get('site_id') + page_urls = request.data.get('page_urls') + tags = request.data.get('tags', None) + configs = request.data.get('configs', None) + no_scan = request.data.get('no_scan', False) + + # get user and account user = request.user - account = Member.objects.get(user=user).account + member = Member.objects.get(user=user) + account = member.account - try: - test = Test.objects.get(id=id) - except: - data = {'reason': 'cannot find a Test with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) + # updating configs if None: + configs = account.configs if configs == None else configs - if test.site.account != account: - data = {'reason': 'retrieve Tests of a Site you do not own'} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + # get site and current pages + site = Site.objects.get(id=site_id) + pages = Page.objects.filter(site=site) - # get images_delta if exists - try: - images_delta = {"average_score": test.images_delta.get('average_score')} - except: - images_delta = None + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='page', action='add', + id=site_id, id_type='site' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + if http_response: + return Response(data, status=check_data['status']) + return data + + # pre check for max_pages + if (pages.count() + len(page_urls)) > account.usage['pages_allowed']: + print('max pages reached') + data = {'reason': 'max pages reached',} + record_api_call(request, data, '402') + if http_response: + return Response(data, status=status.HTTP_402_PAYMENT_REQUIRED) + return data + + # setting defaults + count = len(page_urls) + num_succeeded = 0 + succeeded = [] + num_failed = 0 + failed = [] + this_status = True + + # looping through each "page_url" + for url in page_urls: + + # clean url + if url.endswith('/'): + url = url.rstrip('/') + + # check for duplicates + if not Page.objects.filter(page_url=url, user=user).exists(): + + # adding pages + page = Page.objects.create( + site=site, + page_url=url, + user=user, + tags=tags, + account=account + ) - # get lighthouse_delta if exists - try: - lighthouse_delta = {"scores": test.lighthouse_delta.get('scores')} - except: - lighthouse_delta = None + # deciding on scan + if no_scan == False: + + # create initial scan + scan = Scan.objects.create( + site=site, + page=page, + type=settings.TYPES, + configs=configs + ) + + # update page with new scan data + page.info["latest_scan"]["id"] = str(scan.id) + page.info["latest_scan"]["time_created"] = str(scan.time_created) + page.save() + + # run scanner + scan_page_bg.delay(scan_id=scan.id, configs=configs) + + # update info + succeeded.append(url) + num_succeeded = num_succeeded + 1 - # get lighthouse_delta if exists - try: - yellowlab_delta = {"scores": test.yellowlab_delta['scores']} - except: - yellowlab_delta = None + else: + # update info + this_status = False + failed.append(url) + num_failed = num_failed + 1 + # formatting response data = { - "id": str(test.id), - "site": str(test.site.id), - "tags": test.tags, - "type": test.type, - "time_created": str(test.time_created), - "time_completed": str(test.time_completed), - "pre_scan": str(test.pre_scan.id), - "post_scan": str(test.post_scan.id), - "score": test.score, - "lighthouse_delta": lighthouse_delta, - "yellowlab_delta": yellowlab_delta, - "images_delta": images_delta, + 'success': this_status, + 'num_succeeded': num_succeeded, + 'succeeded': succeeded, + 'num_failed': num_failed, + 'failed': failed, } - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response + # record successful API call + record_api_call(request, data, '201') + + # decide on response type + if http_response: + print('requested http response') + # returning HTTP Response + response = Response(data, status=status.HTTP_201_CREATED) + return response + + # return dict response + print('requested data response') + return data + + +def get_pages(request: object=None) -> object: + """ + Get one or more `Pages` from either + "page_id" or "site_id" + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + # get request data + site_id = request.query_params.get('site_id') + page_id = request.query_params.get('page_id') + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + # check for params + if page_id is None and site_id is None: + data = {'reason': 'neet site or page id'} + record_api_call(request, data, '400') + return Response(data, status=status.HTTP_400_BAD_REQUEST) -def delete_test(request, id): - try: - test = Test.objects.get(id=id) - except: - data = {'reason': 'cannot find a Test with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='page', action='get', + id=(site_id if site_id else page_id), + id_type=('site' if site_id else 'page') + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # getting single page + if page_id != None: - site = test.site - user = request.user - account = Member.objects.get(user=user).account + # get page + page = Page.objects.get(id=page_id) - if site.account != account: - data = {'reason': 'delete Tests of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + # serialize and return + serializer_context = {'request': request,} + serialized = PageSerializer(page, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) - test.delete() + # get site and assocaited pages + site = Site.objects.get(id=site_id) + pages = Page.objects.filter(site=site).order_by('-time_created') - data = {'message': 'Test has been deleted',} - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) + # serialize and return + paginator = LimitOffsetPagination() + result_page = paginator.paginate_queryset(pages, request) + serializer_context = {'request': request,} + serialized = PageSerializer(result_page, many=True, context=serializer_context) + response = paginator.get_paginated_response(serialized.data) + record_api_call(request, response.data, '200') return response -def delete_many_tests(request): + +def get_page(request: object=None, id: str=None) -> object: + """ + Get single `Page` from the passed "id" + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='page', action='get', + id=id, id_type='page' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get page if checks passed + page = Page.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = PageSerializer(page, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def delete_page(request: object=None, id: str=None, user: object=None) -> object: + """ + Deletes the `Page` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account info + if request: + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='page', + action='delete', id=id, id_type='page' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + print(data) + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get page by id + page = Page.objects.get(id=id) + + # remove s3 objects + delete_page_s3_bg.delay(page_id=id, site_id=page.site.id) + + # remove any schedules and associated tasks + delete_tasks_and_schedules(resource_id=str(page.id), scope='page', account=account) + + # remove any associated Issues + Issue.objects.filter(affected__icontains=str(id)).delete() + + # remove page + page.delete() + + # format and return + data = {'message': 'Page has been deleted',} + if request: + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + return data + + + + +def delete_many_pages(request: object=None) -> object: + """ + Deletes one or more `Pages` associated + with the passed "request.ids" + + Expcets: { + 'request' : object, + } + + Returns -> HTTP Response object + """ + + # get request data ids = request.data.get('ids') + + # get user and account user = request.user - account = Member.objects.get(user=user).account + member = Member.objects.get(user=user) + account = member.account + # check for ids if ids is not None: + + # setting defaults count = len(ids) num_succeeded = 0 succeeded = [] @@ -578,29 +1308,41 @@ def delete_many_tests(request): user = request.user this_status = True + # loop through passed ids for id in ids: + + # trying to delete page try: - test = Test.objects.get(id=id) - if test.site.account == account: - test.delete() + # delete page and all assocaited resourses + data = delete_page(id=id, user=user) + if data.get('reason'): + raise Exception + + # add to success attempts num_succeeded += 1 succeeded.append(str(id)) - except: + except Exception as e: + # add to failed attempts + print(e) num_failed += 1 failed.append(str(id)) this_status = False + # format data data = { - 'status': this_status, + 'success': this_status, 'num_succeeded': num_succeeded, 'succeeded': succeeded, 'num_failed': num_failed, 'failed': failed, } + + # returning response record_api_call(request, data, '200') response = Response(data, status=status.HTTP_200_OK) return response + # returning error data = { 'reason': 'you must provide an array of id\'s' } @@ -611,155 +1353,407 @@ def delete_many_tests(request): +def get_pages_zapier(request: object=None) -> object: + """ + Get all `Pages` associated with user's Account. -def create_scan(request, delay=False): + Expects: { + 'request': object + } - user = request.user - account = Member.objects.get(user=user).account - site_id = request.data.get('site_id', None) - configs = request.data.get('configs', None) - types = request.data.get('type', ['html', 'logs', 'vrt', 'lighthouse', 'yellowlab']) - tags = request.data.get('tags', None) + Returns -> HTTP Response object + """ - if len(types) == 0: - types = ['html', 'logs', 'vrt', 'lighthouse', 'yellowlab'] + # get request data + member = Member.objects.get(user=request.user) + account = member.account + site_id = request.query_params.get('site_id') + pages = None - try: - site = Site.objects.get(id=site_id) - except: - data = {'reason': 'cannot find a Site with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) + # deciding on scope + resource = 'page' - account_is_active = check_account(request) - if not account_is_active: - data = {'reason': 'account not funded',} - record_api_call(request, data, '402') - return Response(data, status=status.HTTP_402_PAYMENT_REQUIRED) + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource=resource, action='get', + id=site_id, id_type='site' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + return Response(data, status=check_data['status']) + + # get all site associated pages + if site_id: + pages = Page.objects.filter( + account=account, + site__id=site_id, + ).order_by('-time_created') + + # get all account assocoiated pages + if pages is None: + pages = Page.objects.filter( + account=account, + ).order_by('-time_created') + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + pages = pages.filter(site__id__in=id_list) + + # build response data + data = [] + + for page in pages: + data.append({ + 'id' : str(page.id), + 'page_url' : str(page.page_url), + 'site' : str(page.site.id), + 'site_url' : str(page.site.site_url), + 'time_created' : str(page.time_created), + 'tags' : page.tags, + 'info' : page.info, + }) + + # serialize and return + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +### ------ Begin Scan Services ------ ### + + + + +def create_scan(request: object=None, **kwargs) -> object: + """ + Create one or more `Scans` depanding on + `Page` or `Site` scope + + Expects: { + 'request': object, + } + + Returns -> dict or HTTP Response object + """ + + # check location + location_data = check_location(request, None) + if location_data['routed']: + return location_data['response'] + + # get request data + if request is not None: + site_id = request.data.get('site_id', '') + page_id = request.data.get('page_id', '') + configs = request.data.get('configs', None) + types = request.data.get('type', settings.TYPES) + tags = request.data.get('tags') + user = request.user - if site.account != account: - data = {'reason': 'create a Scan of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + # getting kwargs data + if request is None: + site_id = kwargs.get('site_id', '') + page_id = kwargs.get('page_id', '') + configs = kwargs.get('configs', None) + types = kwargs.get('type', settings.TYPES) + tags = kwargs.get('tags') + user_id = kwargs.get('user_id') + user = User.objects.get(id=user_id) + # getting account + member = Member.objects.get(user=user) + account = member.account + + # updating configs if None: + configs = account.configs if configs == None else configs + + # checking args + site_id = '' if site_id is None else site_id + page_id = '' if page_id is None else page_id + site_id = site_id if len(str(site_id)) > 0 else None + page_id = page_id if len(str(page_id)) > 0 else None + id = site_id if site_id else page_id + id_type = 'site' if site_id else 'page' + + # verifying types + if len(types) == 0: + types = settings.TYPES - if not configs: - configs = { - 'window_size': '1920,1080', - 'interval': 5, - 'driver': 'selenium', - 'device': 'desktop', - 'mask_ids': None, - 'min_wait_time': 10, - 'max_wait_time': 60, - 'timeout': 300, - 'disable_animations': False + # check account and resource for site or page + check_data = check_permissions_and_usage( + member=member, resource='scan', action='add', + id=id, id_type=id_type + ) + if not check_data['allowed']: + data = { + 'reason': check_data['error'], + 'success': False, + 'code': check_data['code'], + 'status': check_data['status'] } + if request is not None: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data - # creating scan obj - created_scan = Scan.objects.create( - site=site, - tags=tags, - type=types, - configs=configs, - ) + # get site or page + if site_id is not None: + site = Site.objects.get(id=site_id) + if page_id is not None: + page = Page.objects.get(id=page_id) + + # setting pages to loop through + if site_id is not None and page_id is None: + pages = Page.objects.filter(site=site) + if site_id is None and page_id is not None: + pages = [page,] + + # setting default + created_scans = [] - if delay == True: + # looping through each page + for p in pages: + + # check for account usage + check_data = check_permissions_and_usage( + member=member, resource='scan', action='add', + ) + if not check_data['allowed']: + data = { + 'reason': check_data['error'], + 'success': False, + 'code': check_data['code'], + 'status': check_data['status'] + } + if request is not None: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # increment account.usage.scans + account.usage['scans'] += 1 + account.save() + + # creating scan obj + created_scan = Scan.objects.create( + site=p.site, + page=p, + tags=tags, + type=types, + configs=configs, + ) - # running scans in selenium mode + # adding scan to array + created_scans.append(str(created_scan.id)) + message = 'Scans are being created in the background' + + # setting format for timestamp + f = '%Y-%m-%d %H:%M:%S.%f' + timestamp = datetime.today().strftime(f) + + # updating latest_scan info for page + p.info['latest_scan']['id'] = str(created_scan.id) + p.info['latest_scan']['time_created'] = timestamp + p.info['latest_scan']['time_completed'] = None + p.info['latest_scan']['score'] = None + p.info['latest_scan']['score'] = None + p.save() + + # updating latest_scan info for site + p.site.info['latest_scan']['id'] = str(created_scan.id) + p.site.info['latest_scan']['time_created'] = timestamp + p.site.info['latest_scan']['time_completed'] = None + p.site.save() + + # running scans components in parallel if 'html' in types or 'logs' in types or 'full' in types: - print('running html & logs') run_html_and_logs_bg.delay(scan_id=created_scan.id) if 'lighthouse' in types or 'full' in types: - print('running lighthouse') run_lighthouse_bg.delay(scan_id=created_scan.id) if 'yellowlab' in types or 'full' in types: - print('running yellowlab') run_yellowlab_bg.delay(scan_id=created_scan.id) if 'vrt' in types or 'full' in types: - print('running vrt') run_vrt_bg.delay(scan_id=created_scan.id) - - - data = { - 'status': True, - 'message': 'scan is being created in the background', - 'id': str(created_scan.id), - } + + # returning dynaminc response + data = { + 'success': True, + 'message': message, + 'ids': created_scans, + } + if request is not None: record_api_call(request, data, '201') return Response(data, status=status.HTTP_201_CREATED) - else: - updated_scan = S(scan=created_scan, configs=configs).first_scan() - serializer_context = {'request': request,} - serialized = ScanSerializer(updated_scan, context=serializer_context) - data = serialized.data - record_api_call(request, data, '201') - response = Response(data, status=status.HTTP_201_CREATED) - return response + return data + +def create_many_scans(request: object=None) -> object: + """ + Bulk creates `Scans` for each requested `Page`. + Either scoped for many `Pages` or many `Sites`. + Expcets: { + 'request' : object, + } + + Returns -> HTTP Response object + """ -def get_scans(request): + # check location + location_data = check_location(request, None) + if location_data['routed']: + return location_data['response'] + # get request data + site_ids = request.data.get('site_ids') + page_ids = request.data.get('page_ids') + configs = request.data.get('configs', None) + types = request.data.get('type', settings.TYPES) + tags = request.data.get('tags') user = request.user - account = Member.objects.get(user=user).account + member = Member.objects.get(user=user) + account = member.account + + # updating configs if None: + configs = account.configs if configs == None else configs + + # setting defaults + num_succeeded = 0 + succeeded = [] + num_failed = 0 + failed = [] + this_status = True + + # scoped for sites + if site_ids: + for id in site_ids: + data = { + 'site_id': str(id), + 'configs': configs, + 'type': types, + 'tags': tags, + 'user_id': str(user.id) + } + try: + # create scan + res = create_scan(**data) + if res['success']: + num_succeeded += 1 + succeeded.append(str(id)) + else: + num_failed += 1 + this_status = False + failed.append(str(id)) + print(res['reason']) + except Exception as e: + print(e) + if str(id) not in failed: + num_failed += 1 + this_status = False + failed.append(str(id)) + + # scoped for pages + if page_ids: + for id in page_ids: + data = { + 'page_id': str(id), + 'configs': configs, + 'type': types, + 'tags': tags, + 'user_id': str(user.id) + } + try: + # create scan + res = create_scan(**data) + if res['success']: + num_succeeded += 1 + succeeded.append(str(id)) + else: + num_failed += 1 + this_status = False + failed.append(str(id)) + print(res['reason']) + except Exception as e: + print(e) + if str(id) not in failed: + num_failed += 1 + this_status = False + failed.append(str(id)) + + # format and return + data = { + 'success': this_status, + 'num_succeeded': num_succeeded, + 'succeeded': succeeded, + 'num_failed': num_failed, + 'failed': failed, + } + record_api_call(request, data, '201') + return Response(data, status=status.HTTP_201_CREATED) + + + + +def get_scans(request: object=None) -> object: + """ + Get one or more `Scans`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data scan_id = request.query_params.get('scan_id') - site_id = request.query_params.get('site_id') - time_begin = request.query_params.get('time_begin') - time_end = request.query_params.get('time_end') + page_id = request.query_params.get('page_id') lean = request.query_params.get('lean') + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # deciding on scope + id = page_id if page_id else scan_id + id_type = 'page' if page_id else 'scan' + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='scan', + action='get', id=id, id_type=id_type + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + # get single scan if scan_id != None: - try: - scan = Scan.objects.get(id=scan_id) - except: - data = {'reason': 'cannot find a Scan with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - if scan.site.account != account: - data = {'reason': 'retrieve Scans of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + + # get scan + scan = Scan.objects.get(id=scan_id) + # serialize and return serializer_context = {'request': request,} serialized = ScanSerializer(scan, context=serializer_context) data = serialized.data record_api_call(request, data, '200') return Response(data, status=status.HTTP_200_OK) - - try: - site = Site.objects.get(id=site_id) - except: - data = {'reason': 'cannot find a Site with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - - if site.account != account: - data = {'reason': 'retrieve Scans of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - - - if time_begin == None and site != None and time_end != None: - scans = Scan.objects.filter(site=site).filter(time_created__lte=time_end).order_by('-time_created') - elif time_end == None and site != None and time_begin != None: - scans = Scan.objects.filter(site=site).filter(time_created__gte=time_begin).order_by('-time_created') - elif time_end == None and time_begin == None and site != None: - scans = Scan.objects.filter(site=site).order_by('-time_created') - elif time_end != None and time_begin != None and site != None: - scans = Scan.objects.filter(site=site).filter(time_created__gte=time_begin).filter(time_created__lte=time_end).order_by('-time_created') - + # get page scoped scans + page = Page.objects.get(id=page_id) + scans = Scan.objects.filter(page=page).order_by('-time_created') + # serialize and return paginator = LimitOffsetPagination() result_page = paginator.paginate_queryset(scans, request) serializer_context = {'request': request,} serialized = ScanSerializer(result_page, many=True, context=serializer_context) - if lean is not None: + if str(lean).lower() == 'true': serialized = SmallScanSerializer(result_page, many=True, context=serializer_context) response = paginator.get_paginated_response(serialized.data) record_api_call(request, response.data, '200') @@ -768,35 +1762,84 @@ def get_scans(request): -def get_scan_lean(request, id): +def get_scan(request: object=None, id: str=None) -> object: + """ + Get single `Scan` from the passed "id" + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account user = request.user - account = Member.objects.get(user=user).account + member = Member.objects.get(user=user) + account = member.account - try: - scan = Scan.objects.get(id=id) - except: - data = {'reason': 'cannot find a Scan with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='scan', action='get', + id=id, id_type='scan' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) - if scan.site.account != account: - data = {'reason': 'retrieve Scans of a Site you do not own'} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + # get scan if checks passed + scan = Scan.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = ScanSerializer(scan, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) - # get lighthouse scores if exists - try: - lighthouse = {"scores": scan.lighthouse.get('scores')} - except: - lighthouse = None - - # get yellowlab scores if exists - try: - yellowlab = {"scores": scan.yellowlab.get('scores')} - except: - yellowlab = None - data = { + + +def get_scan_lean(request: object=None, id: str=None) -> object: + """ + Get a single `Scan` and only return scores & timestamps + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='scan', action='get', + id=id, id_type='scan' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get scan if checks passed + scan = Scan.objects.get(id=id) + + # get lighthouse scores if exists + lighthouse = {"scores": scan.lighthouse.get('scores')} + + # get yellowlab scores if exists + yellowlab = {"scores": scan.yellowlab.get('scores')} + + # format data + data = { "id": str(scan.id), "site": str(scan.site.id), "tags": scan.tags, @@ -807,6 +1850,7 @@ def get_scan_lean(request, id): "yellowlab": yellowlab, } + # return response record_api_call(request, data, '200') response = Response(data, status=status.HTTP_200_OK) return response @@ -814,39 +1858,88 @@ def get_scan_lean(request, id): -def delete_scan(request, id): - try: - scan = Scan.objects.get(id=id) - except Exception as e: - data = {'reason': 'cannot find a Scan with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - site = scan.site - user = request.user - account = Member.objects.get(user=user).account +def delete_scan(request: object=None, id: str=None, user: object=None) -> object: + """ + Deletes the `Scan` associated with the passed "id" + Expcets: { + 'request' : object, + 'id' : str, + 'account' : object, + 'user' : object + } - if site.account != account: - data = {'reason': 'delete Scans of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + Returns -> HTTP Response object + """ + + # get user and account info + if request: + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='scan', action='delete', + id=id, id_type='scan' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get scan if checks passes + scan = Scan.objects.get(id=id) + + # remove s3 objects + delete_scan_s3_bg.delay(scan.id, scan.site.id, scan.page.id) + + # update page and site + update_site_and_page_info.delay( + resource='scan', + page_id=str(scan.page.id) + ) + # delete scan scan.delete() + # return response data = {'message': 'Scan has been deleted',} - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response + if request: + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + return data + + +def delete_many_scans(request: object=None) -> object: + """ + Deletes one or more `Scans` associated + with the passed "request.ids" -def delete_many_scans(request): + Expcets: { + 'request' : object, + } + + Returns -> HTTP Response object + """ + + # get request data ids = request.data.get('ids') + + # get user and account user = request.user - account = Member.objects.get(user=user).account + member = Member.objects.get(user=user) + account = member.account + # check for ids if ids is not None: + + # setting defaults count = len(ids) num_succeeded = 0 succeeded = [] @@ -855,30 +1948,41 @@ def delete_many_scans(request): user = request.user this_status = True + # loop through passed ids for id in ids: + + # trying to delete scan try: - scan = Scan.objects.get(id=id) - if scan.site.account == account: - scan.delete() + # delete scan and all assocaited resourses + data = delete_scan(id=id, user=user) + if data.get('reason'): + raise Exception + + # add to success attempts num_succeeded += 1 succeeded.append(str(id)) - except: + except Exception as e: + # add to failed attempts + print(e) num_failed += 1 failed.append(str(id)) this_status = False + # format data data = { - 'status': this_status, + 'success': this_status, 'num_succeeded': num_succeeded, 'succeeded': succeeded, 'num_failed': num_failed, 'failed': failed, } + # returning response record_api_call(request, data, '200') response = Response(data, status=status.HTTP_200_OK) return response + # return error data = { 'reason': 'you must provide an array of id\'s' } @@ -889,257 +1993,501 @@ def delete_many_scans(request): +def get_scans_zapier(request: object=None) -> object: + """ + Get all `Scans` associated with user's Account. -def create_or_update_schedule(request): - user = request.user - account = Member.objects.get(user=user).account - - account_is_active = check_account(request) - if not account_is_active: - data = {'reason': 'account not funded',} - record_api_call(request, data, '402') - return Response(data, status=status.HTTP_402_PAYMENT_REQUIRED) - - try: - site = Site.objects.get(id=request.data.get('site_id')) - if site.account != account and site.account != None: - data = {'reason': 'create a Schedule of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - except: - site = None - try: - schedule = Schedule.objects.get(id=request.data.get('schedule_id')) - if schedule.account != account and schedule.account != None: - data = {'reason': 'update a Schedule you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - except: - schedule = None - - - schedule_status = request.data.get('status', None) - begin_date_raw = request.data.get('begin_date', None) - time = request.data.get('time', None) - timezone = request.data.get('timezone', None) - freq = request.data.get('frequency', None) - task_type = request.data.get('task_type', None) - test_type = request.data.get('test_type', ['html', 'logs', 'vrt', 'lighthouse', 'yellowlab']) - scan_type = request.data.get('scan_type', ['html', 'logs', 'vrt', 'lighthouse', 'yellowlab']) - configs = request.data.get('configs', None) - schedule_id = request.data.get('schedule_id', None) - case_id = request.data.get('case_id', None) - updates = request.data.get('updates', None) - - - if configs is None: - configs = { - 'window_size': '1920,1080', - 'driver': 'selenium', - 'device': 'desktop', - 'mask_ids': None, - 'interval': 5, - 'min_wait_time': 10, - 'max_wait_time': 30, - 'timeout': 300, - 'disable_animations': False - } + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ - if schedule_status != None and schedule != None: - task = PeriodicTask.objects.get(id=schedule.periodic_task_id) - if task.enabled == True: - task.enabled = False - schedule.status = 'Paused' - else: - task.enabled = True - schedule.status = 'Active' - task.save() - schedule.save() - # retriving object again to avoid cacheing issues - schedule_new = Schedule.objects.get(id=request.data.get('schedule_id')) + # get request data + member = Member.objects.get(user=request.user) + account = member.account + page_id = request.query_params.get('page_id') + site_id = request.query_params.get('site_id') + id = page_id if page_id else site_id + id_type = 'page' if page_id else 'site' + scans = None - # if not status change, updating data - else: - if Automation.objects.filter(schedule=schedule).exists(): - automation = Automation.objects.filter(schedule=schedule)[0] - auto_id = str(automation.id) - else: - auto_id = None - - if task_type == 'test': - task = 'api.tasks.create_test_bg' - arguments = { - 'site_id': str(site.id), - 'configs': configs, - 'type': test_type, - 'automation_id': auto_id - } + # deciding on scope + resource = 'scan' - if task_type == 'scan': - task = 'api.tasks.create_scan_bg' - arguments = { - 'site_id': str(site.id), - 'configs': configs, - 'type': scan_type, - 'automation_id': auto_id - } + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource=resource, + action='get', id=id, id_type=id_type + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + return Response(data, status=check_data['status']) + + # get all page associated scans + if page_id: + scans = Scan.objects.filter( + page__account=account, + page__id=page_id, + ).exclude( + time_completed=None, + ).order_by('-time_created') + + # get all site associated scans + if site_id: + scans = Scan.objects.filter( + site__account=account, + site__id=site_id, + ).exclude( + time_completed=None, + ).order_by('-time_created') + + # get all account assocoiated scans + if scans is None: + scans = Scan.objects.filter( + site__account=account, + ).exclude( + time_completed=None, + ).order_by('-time_created') + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + scans = scans.filter(site__id__in=id_list).order_by('-time_created') + + # build response data + data = [] + + for scan in scans: + data.append({ + 'id' : str(scan.id), + 'page' : str(scan.page.id), + 'site' : str(scan.site.id), + 'time_created' : str(scan.time_created), + 'time_completed' : str(scan.time_completed), + 'type' : scan.type, + 'html' : scan.html, + 'logs' : scan.logs, + 'images' : scan.images, + 'lighthouse' : scan.lighthouse, + 'yellowlab' : scan.yellowlab, + 'configs' : scan.configs, + }) + + # serialize and return + response = Response(data, status=status.HTTP_200_OK) + return response - if task_type == 'report': - task = 'api.tasks.create_report_bg' - arguments = { - 'site_id': str(site.id), - 'automation_id': auto_id - } - if task_type == 'testcase': - task = 'api.tasks.create_testcase_bg' - arguments = { - 'site_id': str(site.id), - 'case_id': str(case_id), - 'updates': updates, - 'configs': configs, - 'automation_id': auto_id, - } - format_str = '%m/%d/%Y' - - try: - begin_date = datetime.strptime(begin_date_raw, format_str) - except: - begin_date = datetime.now() +### ------ Begin Test Services ------ ### - num_day_of_week = begin_date.weekday() - day = begin_date.strftime("%d") - minute = time[3:5] - hour = time[0:2] - if freq == 'daily': - day_of_week = '*' - day_of_month = '*' - elif freq == 'weekly': - day_of_week = num_day_of_week - day_of_month = '*' - elif freq == 'monthly': - day_of_week = '*' - day_of_month = day - task_name = str(task_type) + '_' + str(site.site_url) + '_' + str(freq) + '_@' + str(time) +def create_test(request: object=None, **kwargs) -> object: + """ + Create one or more `Tests` depanding on + `Page` or `Site` scope - crontab, _ = CrontabSchedule.objects.get_or_create( - timezone=timezone, minute=minute, hour=hour, - day_of_week=day_of_week, day_of_month=day_of_month, - ) + Expects: { + 'request': object, + 'delay': bool + } - if schedule: - if PeriodicTask.objects.filter(id=schedule.periodic_task_id).exists(): - periodic_task = PeriodicTask.objects.filter(id=schedule.periodic_task_id) - periodic_task.update( - crontab=crontab, - name=task_name, task=task, - kwargs=json.dumps(arguments), - ) - periodic_task = PeriodicTask.objects.get(id=schedule.periodic_task_id) - else: - periodic_task = PeriodicTask.objects.create( - crontab=crontab, name=task_name, task=task, - kwargs=json.dumps(arguments), - ) + Returns -> dict or HTTP Response object + """ - else: - if PeriodicTask.objects.filter(name=task_name).exists(): - data = {'reason': 'Task has already be created',} - record_api_call(request, data, '401') - return Response(data, status=status.HTTP_401_UNAUTHORIZED) - - periodic_task = PeriodicTask.objects.create( - crontab=crontab, name=task_name, task=task, - kwargs=json.dumps(arguments), - ) + # check location + location_data = check_location(request, None) + if location_data['routed']: + return location_data['response'] - extras = { - "configs": configs, - "test_type": test_type, - "scan_type": scan_type, - "case_id": case_id, - "updates": updates + # get data from request + if request is not None: + configs = request.data.get('configs', None) + threshold = request.data.get('threshold', settings.TEST_THRESHOLD) + pre_scan_id = request.data.get('pre_scan') + post_scan_id = request.data.get('post_scan') + index = request.data.get('index') + test_type = request.data.get('type', settings.TYPES) + tags = request.data.get('tags') + pre_scan = None + post_scan = None + site_id = request.data.get('site_id', '') + page_id = request.data.get('page_id', '') + user = request.user + + # get data from kwargs + if request is None: + configs = kwargs.get('configs', None) + threshold = kwargs.get('threshold', settings.TEST_THRESHOLD) + pre_scan_id = kwargs.get('pre_scan') + post_scan_id = kwargs.get('post_scan') + index = kwargs.get('index') + test_type = kwargs.get('type', settings.TYPES) + tags = kwargs.get('tags') + pre_scan = None + post_scan = None + site_id = kwargs.get('site_id', '') + page_id = kwargs.get('page_id', '') + user_id = kwargs.get('user_id') + user = User.objects.get(id=user_id) + + # get account + member = Member.objects.get(user=user) + account = member.account + + # updating configs if None: + configs = account.configs if configs == None else configs + + # verifying test_type + if len(test_type) == 0: + test_type = settings.TYPES + + # checking args + site_id = '' if site_id is None else site_id + page_id = '' if page_id is None else page_id + site_id = site_id if len(str(site_id)) > 0 else None + page_id = page_id if len(str(page_id)) > 0 else None + id = site_id if site_id else page_id + id_type = 'site' if site_id else 'page' + + # check account and resource for page or site + check_data = check_permissions_and_usage( + member=member, resource='test', action='add', + id=id, id_type=id_type + ) + if not check_data['allowed']: + data = { + 'reason': check_data['error'], + 'success': False, + 'code': check_data['code'], + 'status': check_data['status'] } + if request is not None: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # deciding on scope + if site_id is not None: + site = Site.objects.get(id=site_id) + if page_id is not None: + page = Page.objects.get(id=page_id) + + # building pages list + if site_id is not None and page_id is None: + pages = Page.objects.filter(site=site) + if site_id is None and page_id is not None: + pages = [page] + + # setting default + created_tests = [] + + # looping through pages + for p in pages: + + # check for account usage + check_data = check_permissions_and_usage( + member=member, action='add', resource='test' + ) + if not check_data['allowed']: + data = { + 'reason': check_data['error'], + 'success': False, + 'code': check_data['code'], + 'status': check_data['status'] + } + if request is not None: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # checking for scan completion + if not Scan.objects.filter(page=p).exists(): + data = {'reason': 'Page not yet onboarded', 'success': False,} + print(data) + record_api_call(request, data, '400') + return Response(data, status=status.HTTP_400_BAD_REQUEST) - if schedule: - schedule_query = Schedule.objects.filter(id=schedule_id) - if schedule_query.exists(): - schedule_query.update( - user=request.user, timezone=timezone, - begin_date=begin_date, time=time, frequency=freq, - task=task, crontab_id=crontab.id, task_type=task_type, - extras=extras, account=account - ) - schedule_new = Schedule.objects.get(id=schedule_id) - else: - schedule_new = Schedule.objects.create( - user=request.user, site=site, task_type=task_type, timezone=timezone, - begin_date=begin_date, time=time, frequency=freq, - task=task, crontab_id=crontab.id, - periodic_task_id=periodic_task.id, - extras=extras, - account=account - ) + # verifying pre_ and post_ scans exists + if pre_scan_id: + try: + pre_scan = Scan.objects.get(id=pre_scan_id) + except: + data = {'reason': 'cannot find a Scan with that id - pre_scan', 'success': False,} + print(data) + if request is not None: + record_api_call(request, data, '404') + return Response(data, status=status.HTTP_404_NOT_FOUND) + return data + if post_scan_id: + try: + post_scan = Scan.objects.get(id=post_scan_id) + except: + data = {'reason': 'cannot find a Scan with that id - post_scan', 'success': False,} + print(data) + if request is not None: + record_api_call(request, data, '404') + return Response(data, status=status.HTTP_404_NOT_FOUND) + return data + + # grabbing most recent Scan + if pre_scan_id is None: + pre_scan = Scan.objects.filter(page=p).order_by('-time_created')[0] + + # verifying pre_ and post_ scans completion + if pre_scan: + if pre_scan.time_completed == None: + data = {'reason': 'pre_scan still running', 'success': False,} + print(data) + if request is not None: + record_api_call(request, data, '400') + return Response(data, status=status.HTTP_400_BAD_REQUEST) + return data + if post_scan: + if post_scan.time_completed == None: + data = {'reason': 'post_scan still running', 'success': False,} + print(data) + if request is not None: + record_api_call(request, data, '400') + return Response(data, status=status.HTTP_400_BAD_REQUEST) + return data + + # creating test object + test = Test.objects.create( + site=p.site, + page=p, + type=test_type, + tags=tags, + threshold=float(threshold), + status='working', + ) + + # setting format for timestamp + f = '%Y-%m-%d %H:%M:%S.%f' + timestamp = datetime.today().strftime(f) + + # updating latest_test info for page + p.info['latest_test']['id'] = str(test.id) + p.info['latest_test']['time_created'] = timestamp + p.info['latest_test']['time_completed'] = None + p.info['latest_test']['score'] = None + p.info['latest_test']['status'] = 'working' + p.save() + + # updating latest_test info for site + p.site.info['latest_test']['id'] = str(test.id) + p.site.info['latest_test']['time_created'] = timestamp + p.site.info['latest_test']['time_completed'] = None + p.site.info['latest_test']['score'] = None + p.site.info['latest_test']['status'] = 'working' + p.site.save() + + # add test.id to list + created_tests.append(str(test.id)) + + # update account.usage.tests + account.usage['tests'] += 1 + account.save() + + # running test in background + create_test_bg.delay( + test_id=test.id, + configs=configs, + type=test_type, + index=index, + pre_scan=pre_scan_id, + post_scan=post_scan_id, + tags=tags, + threshold=float(threshold), + ) + message = 'Tests are being created in the background' + + # returning dynaminc response + data = { + 'success': True, + 'message': message, + 'ids': created_tests, + } + if request is not None: + record_api_call(request, data, '201') + return Response(data, status=status.HTTP_201_CREATED) + return data - serializer_context = {'request': request,} - data = ScheduleSerializer(schedule_new, context=serializer_context).data - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response +def create_many_tests(request: object=None) -> object: + """ + Bulk creates `Tests` for each requested `Page`. + Either scoped for many `Pages` or many `Sites`. -def get_schedules(request): + Expcets: { + 'request' : object, + } + + Returns -> HTTP Response object + """ + + # check location + location_data = check_location(request, None) + if location_data['routed']: + return location_data['response'] + + # get request data + site_ids = request.data.get('site_ids') + page_ids = request.data.get('page_ids') + configs = request.data.get('configs', None) + threshold = request.data.get('threshold', settings.TEST_THRESHOLD) + types = request.data.get('type', settings.TYPES) + tags = request.data.get('tags') user = request.user - account = Member.objects.get(user=user).account - schedule_id = request.query_params.get('schedule_id') - site_id = request.query_params.get('site_id') + member = Member.objects.get(user=user) + account = member.account + + # updating configs if None: + configs = account.configs if configs == None else configs + + # setting defaults + num_succeeded = 0 + succeeded = [] + num_failed = 0 + failed = [] + this_status = True + + # scoped for sites + if site_ids: + for id in site_ids: + data = { + 'site_id': str(id), + 'configs': configs, + 'threshold': threshold, + 'type': types, + 'tags': tags, + 'user_id': str(user.id) + } + try: + # create test + res = create_test(**data) + if res['success']: + num_succeeded += 1 + succeeded.append(str(id)) + else: + num_failed += 1 + this_status = False + failed.append(str(id)) + print(res['reason']) + except Exception as e: + print(e) + if str(id) not in failed: + num_failed += 1 + this_status = False + failed.append(str(id)) + + # scoped for pages + if page_ids: + for id in page_ids: + data = { + 'page_id': str(id), + 'configs': configs, + 'threshold': threshold, + 'type': types, + 'tags': tags, + 'user_id': str(user.id) + } + try: + # create test + res = create_test(**data) + if res['success']: + num_succeeded += 1 + succeeded.append(str(id)) + else: + num_failed += 1 + this_status = False + failed.append(str(id)) + print(res['reason']) + except Exception as e: + print(e) + if str(id) not in failed: + num_failed += 1 + this_status = False + failed.append(str(id)) + + # format and return + data = { + 'success': this_status, + 'num_succeeded': num_succeeded, + 'succeeded': succeeded, + 'num_failed': num_failed, + 'failed': failed, + } + record_api_call(request, data, '201') + return Response(data, status=status.HTTP_201_CREATED) + - if schedule_id != None: - try: - schedule = Schedule.objects.get(id=schedule_id) - except: - data = {'reason': 'cannot find a Schedule with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - if schedule.site.account != user or schedule.account != account: - data = {'reason': 'retrieve Schedules of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + +def get_tests(request: object=None) -> object: + """ + Get one or more `Tests`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + test_id = request.query_params.get('test_id') + page_id = request.query_params.get('page_id') + lean = request.query_params.get('lean') + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # deciding on scope + id = test_id if test_id else page_id + id_type = 'page' if page_id else 'test' + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='test', + action='add',id=id, id_type=id_type + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single test + if test_id != None: + # get test + test = Test.objects.get(id=test_id) + + # serialize and return serializer_context = {'request': request,} - serialized = ScheduleSerializer(schedule, context=serializer_context) + serialized = TestSerializer(test, context=serializer_context) data = serialized.data record_api_call(request, data, '200') return Response(data, status=status.HTTP_200_OK) + # get all page scoped tests + page = Page.objects.get(id=page_id) + tests = Test.objects.filter(page=page).order_by('-time_created') - try: - site = Site.objects.get(id=site_id) - except: - data = {'reason': 'cannot find a Site with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - if site.account != account: - data = {'reason': 'retrieve Schedules of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - - schedules = Schedule.objects.filter(site=site).order_by('-time_created') - + # serialize and return paginator = LimitOffsetPagination() - result_page = paginator.paginate_queryset(schedules, request) + result_page = paginator.paginate_queryset(tests, request) serializer_context = {'request': request,} - serialized = ScheduleSerializer(result_page, many=True, context=serializer_context) + serialized = TestSerializer(result_page, many=True, context=serializer_context) + if str(lean).lower() == 'true': + serialized = SmallTestSerializer(result_page, many=True, context=serializer_context) response = paginator.get_paginated_response(serialized.data) record_api_call(request, response.data, '200') return response @@ -1147,224 +2495,3716 @@ def get_schedules(request): -def delete_schedule(request, id): +def get_test(request: object=None, id: str=None) -> object: + """ + Get single `Test` from the passed "id" - try: - schedule = Schedule.objects.get(id=id) - except: - data = {'reason': 'cannot find a Schedule with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) + Expects: { + 'request' : object, + 'id' : str + } - task = PeriodicTask.objects.get(id=schedule.periodic_task_id) - site = schedule.site - user = request.user - account = Member.objects.get(user=user).account + Returns -> HTTP Response object + """ - if site.account != account: - data = {'reason': 'delete Schedules you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account - schedule.delete() - task.delete() + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='test', + action='get', id=id, id_type='test' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) - data = {'message': 'Schedule has been deleted',} + # get test if checks passed + test = Test.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = TestSerializer(test, context=serializer_context) + data = serialized.data record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response + return Response(data, status=status.HTTP_200_OK) +def get_test_lean(request: object=None, id: str=None) -> object: + """ + Get a single `Test` and only return scores & timestamps + Expects: { + 'request' : object, + 'id' : str + } + Returns -> HTTP Response object + """ -def create_or_update_automation(request): + # get user and account user = request.user - account = Member.objects.get(user=user).account - - account_is_active = check_account(request) - if not account_is_active: - data = {'reason': 'account not funded',} - record_api_call(request, data, '402') - return Response(data, status=status.HTTP_402_PAYMENT_REQUIRED) - - try: - schedule = Schedule.objects.get(id=request.data.get('schedule_id')) - try: - automation = Automation.objects.get(id=schedule.automation.id) - if automation.account != account and automation.account != None: - data = {'reason': 'update a Automation you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - except: - automation = None - if schedule.account != account and schedule.account != None: - data = {'reason': 'create a Automation of a Schedule you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - except: - schedule = None - automation = None - - # get data - name = request.data.get('name') - expressions = request.data.get('expressions') - actions = request.data.get('actions') - - if automation: - automation.name = name - automation.expressions = expressions - automation.actions = actions - automation.schedule = schedule - automation.save() - - if not automation: - automation = Automation.objects.create( - name=name, expressions=expressions, actions=actions, - schedule=schedule, user=request.user, account=account - ) + member = Member.objects.get(user=user) + account = member.account - if schedule: - schedule.automation = automation - schedule.save() - # update associated periodicTask - task = PeriodicTask.objects.get(id=schedule.periodic_task_id) - arguments = { - 'site_id': str(schedule.site.id), - 'automation_id': str(automation.id), - 'configs': json.loads(task.kwargs).get('configs', None), - 'type': json.loads(task.kwargs).get('type', None), - 'case_id': json.loads(task.kwargs).get('case_id', None), - 'updates': json.loads(task.kwargs).get('updates', None) - } - task.kwargs=json.dumps(arguments) - task.save() + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='test', + action='get', id=id, id_type='test' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get test if checks passed + test = Test.objects.get(id=id) - serializer_context = {'request': request,} - data = AutomationSerializer(automation, context=serializer_context).data - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response + # get images_delta if exists + images_delta = {"average_score": test.images_delta.get('average_score')} - + # get lighthouse_delta if exists + lighthouse_delta = {"scores": test.lighthouse_delta.get('scores')} -def get_automations(request): - automation_id = request.query_params.get('automation_id') - user = request.user - account = Member.objects.get(user=user).account + # get lighthouse_delta if exists + yellowlab_delta = {"scores": test.yellowlab_delta['scores']} - if automation_id != None: - try: - automation = Automation.objects.get(id=automation_id) - except: - data = {'reason': 'cannot find a Automation with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - if automation.account != account: - data = {'reason': 'retrieve an Automation you do not own',} - return Response(data, status=status.HTTP_403_FORBIDDEN) - serializer_context = {'request': request,} - serialized = AutomationSerializer(automation, context=serializer_context) + # format data + data = { + "id": str(test.id), + "site": str(test.site.id), + "tags": test.tags, + "type": test.type, + "time_created": str(test.time_created), + "time_completed": str(test.time_completed), + "pre_scan": str(test.pre_scan.id), + "post_scan": str(test.post_scan.id), + "score": test.score, + "lighthouse_delta": lighthouse_delta, + "yellowlab_delta": yellowlab_delta, + "images_delta": images_delta, + } + + # return + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +def delete_test(request: object=None, id: str=None, user: object=None) -> object: + """ + Deletes the `Test` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str, + 'account' : object, + } + + Returns -> HTTP Response object + """ + + # get user and account info + if request: + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='test', + action='delete', id=id, id_type='test' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get test if checks passed + test = Test.objects.get(id=id) + + # remove s3 objects + delete_test_s3_bg.delay(test.id, test.site.id, test.page.id) + + # update site and page with most recent data + update_site_and_page_info.delay( + resource='test', + page_id=str(test.page.id) + ) + + # delete test + test.delete() + + # return response + data = {'message': 'Test has been deleted',} + if request: + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + return data + + + + +def delete_many_tests(request: object=None) -> object: + """ + Deletes one or more `Tests` associated + with the passed "request.ids" + + Expcets: { + 'request' : object, + } + + Returns -> HTTP Response object + """ + + # get request data + ids = request.data.get('ids') + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check for ids + if ids is not None: + + # setting defaults + count = len(ids) + num_succeeded = 0 + succeeded = [] + num_failed = 0 + failed = [] + user = request.user + this_status = True + + # loop through passed ids + for id in ids: + + # trying to delete test + try: + # delete test and all assocaited resourses + data = delete_test(id=id, user=user) + if data.get('reason'): + raise Exception + + # add to success attempts + num_succeeded += 1 + succeeded.append(str(id)) + except Exception as e: + # add to failed attempts + print(e) + num_failed += 1 + failed.append(str(id)) + this_status = False + + # format data + data = { + 'success': this_status, + 'num_succeeded': num_succeeded, + 'succeeded': succeeded, + 'num_failed': num_failed, + 'failed': failed, + } + + # return response + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + + # return error + data = { + 'reason': 'you must provide an array of id\'s' + } + record_api_call(request, data, '400') + response = Response(data, status=status.HTTP_400_BAD_REQUEST) + return response + + + + +def get_tests_zapier(request: object=None) -> object: + """ + Get all `Tests` associated with user's Account. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + member = Member.objects.get(user=request.user) + account = member.account + page_id = request.query_params.get('page_id') + site_id = request.query_params.get('site_id') + id = page_id if page_id else site_id + id_type = 'page' if page_id else 'site' + _status = request.query_params.get('status') + tests = None + + # deciding on scope + resource = 'test' + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource=resource, + action='get', id=id, id_type=id_type + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + return Response(data, status=check_data['status']) + + # get all page associated tests + if page_id: + tests = Test.objects.filter( + page__account=account, + page__id=page_id, + ).exclude( + time_completed=None, + pre_scan=None, + post_scan=None, + ).order_by('-time_created') + + # get all site associated tests + if site_id: + tests = Test.objects.filter( + site__account=account, + site__id=site_id, + ).exclude( + time_completed=None, + pre_scan=None, + post_scan=None, + ).order_by('-time_created') + + # get all account assocoiated tests + if tests is None: + tests = Test.objects.filter( + site__account=account, + ).exclude( + time_completed=None, + pre_scan=None, + post_scan=None, + ).order_by('-time_created') + + # filter my status if requested + if status is not None: + tests = tests.filter(status=_status) + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + tests = tests.filter(site__id__in=id_list).order_by('-time_created') + + # build response data + data = [] + + for test in tests: + data.append({ + 'id' : str(test.id), + 'page' : str(test.page.id), + 'site' : str(test.site.id), + 'pre_scan' : str(test.pre_scan.id) if test.pre_scan else None, + 'post_scan' : str(test.post_scan.id) if test.post_scan else None, + 'time_created' : str(test.time_created), + 'time_completed' : str(test.time_completed), + 'type' : test.type, + 'status' : str(test.status), + 'score' : test.score, + 'threshold' : test.threshold, + 'component_scores' : test.component_scores, + }) + + # serialize and return + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +### ------ Begin Issue Services ------ ### + + + +def create_or_update_issue(request: object=None, **kwargs) -> object: + """ + Creates or Updates an `Issue` + + Expects: { + 'request': object + 'kwargs': dict + } + + Returns -> HTTP Response object + """ + + # get request data + if request is not None: + id = request.data.get('id') + trigger = request.data.get('trigger') + title = request.data.get('title') + details = request.data.get('details') + _status = request.data.get('status') + affected = request.data.get('affected') + labels = request.data.get('labels') + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # get kwargs data + if request is None: + id = kwargs.get('id') + trigger = kwargs.get('trigger') + title = kwargs.get('title') + details = kwargs.get('details') + _status = kwargs.get('status') + affected = kwargs.get('affected') + labels = kwargs.get('labels') + account_id = kwargs.get('account_id') + user_id = kwargs.get('user_id') + user = User.objects.get(id=user_id) + member = Member.objects.get(user=user) + account = Account.objects.get(id=account_id) + + # decide on action + action = 'update' if id else 'add' + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='issue', + action=action, id=id, id_type='issue' + ) + if not check_data['allowed']: + data = { + 'reason': check_data['error'], + 'success': False, + 'code': check_data['code'], + 'status': check_data['status'] + } + if request is not None: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get Issue if id is present + if id is not None: + issue = Issue.objects.get(id=id) + + # update data + if trigger is not None: + issue.trigger = trigger + if title is not None: + issue.title = title + if details is not None: + issue.details = details + if _status is not None: + issue.status = _status + if affected is not None: + issue.affected = affected + if labels is not None: + issue.labels = labels + + # save new data + issue.save() + + # create new Issue + if id is None: + issue = Issue.objects.create( + account = account, + title = title, + details = details, + labels = labels, + trigger = trigger, + affected = affected + ) + + # decide on response type + if request is not None: + # serialize and return + serializer_context = {'request': request,} + serialized = IssueSerializer(issue, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + # return object response + data = { + 'success': True, + 'issue': issue, + } + return data + + + + +def update_many_issues(request: object=None) -> object: + """ + Updates many `Issues` passed in a list + + Expects: { + 'ids' : list + 'updates' : dict + } + + Returns -> HTTP Response object + """ + + # get request data + ids = request.data.get('ids') + updates = request.data.get('updates') + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # set defaults + num_succeeded = 0 + succeeded = [] + num_failed = 0 + failed = [] + this_status = True + + # loop through ids and update + for id in ids: + # reformat update data + data = updates + data['id'] = str(id) + data['account_id'] = str(account.id) + data['user_id'] = str(user.id) + + # send update + try: + data = create_or_update_issue(**data) + if data.get('reason'): + raise Exception + + # add to success attempts + num_succeeded += 1 + succeeded.append(str(id)) + + except Exception as e: + print(e) + if str(id) not in failed: + num_failed += 1 + this_status = False + failed.append(str(id)) + + # format and return + data = { + 'success': this_status, + 'num_succeeded': num_succeeded, + 'succeeded': succeeded, + 'num_failed': num_failed, + 'failed': failed, + } + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def get_issues(request: object=None) -> object: + """ + Get one or more `Issues`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + issue_id = request.query_params.get('issue_id') + site_id = request.query_params.get('site_id') + page_id = request.query_params.get('page_id') + + user = request.user + member = Member.objects.get(user=user) + account = member.account + issues = None + + # deciding on scope + resource = 'issue' + id = issue_id if issue_id else (site_id if site_id else page_id) + id_type = 'issue' if issue_id else ('site' if site_id else 'page') + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource=resource, action='get', + id=id, id_type=id_type, + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single issue + if issue_id != None: + + # get test + issue = Issue.objects.get(id=issue_id) + + # serialize and return + serializer_context = {'request': request,} + serialized = IssueSerializer(issue, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + # get all issues scoped page if page_id passed + if page_id is not None: + issues = Issue.objects.filter( + affected__icontains={'id': page_id}, + account=account + ).order_by('-status', '-time_created') + # get all issues scoped page if page_id passed + if site_id is not None: + issues = Issue.objects.filter( + affected__icontains={'id': site_id}, + account=account + ).order_by('-status', '-time_created') + + # get all account assocoiated issues + if issues is None: + issues = Issue.objects.filter( + account=account + ).order_by('-status', '-time_created') + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + new_ids = id_list + for id in id_list: + for page in Page.objects.filter(site__id=id): + new_ids.append(str(page.id)) + issues = issues.filter(affected__id__in=new_ids).order_by('-time_created') + + # serialize and return + paginator = LimitOffsetPagination() + result_page = paginator.paginate_queryset(issues, request) + serializer_context = {'request': request,} + serialized = IssueSerializer(result_page, many=True, context=serializer_context) + response = paginator.get_paginated_response(serialized.data) + record_api_call(request, response.data, '200') + return response + + + + +def get_issue(request: object=None, id: str=None) -> object: + """ + Get single `Issue` from the passed "id" + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='issue', action='get', + id=id, id_type='issue' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get issue if checks passed + issue = Issue.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = IssueSerializer(issue, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def search_issues(request: object=None) -> object: + """ + Searches for matching `Issues` to the passed + "query" + + Expects: { + 'request': obejct + } + + Returns -> HTTP Response object + """ + + # get request data + user = request.user + member = Member.objects.get(user=user) + account = member.account + query = request.query_params.get('query') + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='issue', action='get' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + + # search for issues + issues = Issue.objects.filter( + Q(account=account, title__icontains=query) | + Q(account=account, details__icontains=query) | + Q(account=account, affected__icontains=query) + ).order_by('-status', '-time_created') + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + new_ids = id_list + for id in id_list: + for page in Page.objects.filter(site__id=id): + new_ids.append(str(page.id)) + issues = issues.filter(affected__id__in=new_ids).order_by('-time_created') + + # serialize and rerturn + paginator = LimitOffsetPagination() + result_page = paginator.paginate_queryset(issues, request) + serializer_context = {'request': request,} + serialized = IssueSerializer(result_page, many=True, context=serializer_context) + response = paginator.get_paginated_response(serialized.data) + record_api_call(request, response.data, '200') + return response + + + + +def delete_issue(request: object=None, id: str=None, user: object=None) -> object: + """ + Deletes the `Issue` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account info + if request: + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='issue', action='delete', + id=id, id_type='issue', + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get issue if checks passed + issue = Issue.objects.get(id=id) + + # delete test + issue.delete() + + # return response + data = {'message': 'Issue has been deleted',} + if request: + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + return data + + + + +def delete_many_issues(request: object=None) -> object: + """ + Deletes many `Issues` passed in a list + + Expects: { + 'ids': list + } + + Returns -> HTTP Response object + """ + + # get request data + ids = request.data.get('ids') + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # set defaults + num_succeeded = 0 + succeeded = [] + num_failed = 0 + failed = [] + this_status = True + + # loop through ids and delete + for id in ids: + + # trying to delete issue + try: + # delete issue and all assocaited resourses + data = delete_issue(id=id, user=user) + if data.get('reason'): + raise Exception + + # add to success attempts + num_succeeded += 1 + succeeded.append(str(id)) + except Exception as e: + # add to failed attempts + print(e) + num_failed += 1 + failed.append(str(id)) + this_status = False + + # format and return + data = { + 'success': this_status, + 'num_succeeded': num_succeeded, + 'succeeded': succeeded, + 'num_failed': num_failed, + 'failed': failed, + } + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def get_issues_zapier(request: object=None) -> object: + """ + Get all `Issues` associated with user's Account. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + member = Member.objects.get(user=request.user) + account = member.account + page_id = request.query_params.get('page_id') + site_id = request.query_params.get('site_id') + id = page_id if page_id else site_id + id_type = 'page' if page_id else 'site' + issues = None + + # deciding on scope + resource = 'issue' + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource=resource, + action='get', id=id, id_type=id_type + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + return Response(data, status=check_data['status']) + + # get all page associated issues + if page_id: + issues = Issue.objects.filter( + account=account, + affected__icontains=page_id, + ).order_by('-status','-time_created') + + # get all site associated issues + if site_id: + issues = Issue.objects.filter( + account=account, + affected__icontains=site_id, + ).order_by('-status', '-time_created') + + # get all account assocoiated issues + if issues is None: + issues = Issue.objects.filter( + account=account + ).order_by('-status', '-time_created') + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + new_ids = id_list + for id in id_list: + for page in Page.objects.filter(site__id=id): + new_ids.append(str(page.id)) + issues = issues.filter(affected__id__in=new_ids).order_by('-time_created') + + # build response data + data = [] + + for issue in issues: + data.append({ + 'id' : str(issue.id), + 'title' : str(issue.title), + 'time_created' : str(issue.time_created), + 'details' : str(issue.details), + 'trigger' : issue.trigger, + 'affected' : issue.affected, + 'labels' : issue.labels, + 'status' : str(issue.status), + }) + + # serialize and return + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +### ------ Begin Schedule Services ------ ### + + + + +def create_or_update_schedule(request: object=None, **kwargs) -> object: + """ + Creates or Updates a `Schedule` + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + if request: + schedule_status = request.data.get('status') + begin_date_raw = request.data.get('begin_date') + time = request.data.get('time') + timezone = request.data.get('timezone') + freq = request.data.get('frequency') + task_type = request.data.get('task_type') + types = request.data.get('type', settings.TYPES) + configs = request.data.get('configs', None) + threshold = request.data.get('threshold', settings.TEST_THRESHOLD) + schedule_id = request.data.get('schedule_id') + resources = request.data.get('resources') + scope = request.data.get('scope') + case_id = request.data.get('case_id') + flow_id = request.data.get('flow_id') + updates = request.data.get('updates') + user = request.user + + if not request: + schedule_status = kwargs.get('status') + begin_date_raw = kwargs.get('begin_date') + time = kwargs.get('time') + timezone = kwargs.get('timezone') + freq = kwargs.get('frequency') + task_type = kwargs.get('task_type') + types = kwargs.get('type', settings.TYPES) + configs = kwargs.get('configs', None) + threshold = kwargs.get('threshold', settings.TEST_THRESHOLD) + schedule_id = kwargs.get('schedule_id') + resources = kwargs.get('resources') + scope = kwargs.get('scope') + case_id = kwargs.get('case_id') + flow_id = kwargs.get('flow_id') + updates = kwargs.get('updates') + user_id = kwargs.get('user_id') + user = User.objects.get(id=user_id) + + + # get account + member = Member.objects.get(user=user) + account = member.account + + # updating configs if None: + configs = account.configs if configs == None else configs + + # setting defaults + schedule = None + + # deciding on action type + action = 'add' if not schedule_id else 'update' + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='schedule', + action=action, id=schedule_id, id_type='schedule' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get schedule if checks passed and id is present + if schedule_id: + schedule = Schedule.objects.get(id=schedule_id) + + # toggling schedule status + if schedule_status != None and schedule != None: + # update task + task = PeriodicTask.objects.get(id=schedule.periodic_task_id) + if schedule_status == 'Paused': + task.enabled = False + if schedule_status == 'Active': + task.enabled = True + # update schedule + schedule.status = schedule_status + task.save() + schedule.save() + + # creating or updating schedule + if not schedule_status: + + # get alert if schedule exists + alert_id = None + if schedule: + if Alert.objects.filter(schedule=schedule).exists(): + alert = Alert.objects.filter(schedule=schedule)[0] + alert_id = str(alert.id) + + # build task + task = f'api.tasks.create_{task_type}_bg' + + # build args + arguments = { + 'scope': scope, + 'resources': resources, + 'account_id': str(account.id), + 'updates': updates, + 'configs': configs, + 'case_id': case_id, + 'flow_id': flow_id, + 'type': types, + 'threshold': threshold, + 'alert_id': alert_id, + } + + # setting start date default + begin_date = datetime.now() + + # parsing begin date + if begin_date_raw: + # begin_date = datetime.strptime(begin_date_raw, '%Y-%m-%d %H:%M:%S.%f') + begin_date = datetime.fromisoformat(begin_date_raw[:-1] + '+00:00') + + # building cron expression time & date + num_day_of_week = begin_date.weekday() + day = begin_date.strftime("%d") + minute = time[3:5] + hour = time[0:2] + + # building cron expression freq + if freq == 'daily': + day_of_week = '*' + day_of_month = '*' + elif freq == 'weekly': + day_of_week = num_day_of_week + day_of_month = '*' + elif freq == 'monthly': + day_of_week = '*' + day_of_month = day + + # create unique str for + rand_str = secrets.token_urlsafe(6) + + # building unique task name + task_name = f'{task_type}_{scope}_{rand_str}_{freq}_@{time}_{account.user.id}' + + # building or updating crontab + crontab, _ = CrontabSchedule.objects.get_or_create( + timezone=timezone, + minute=minute, + hour=hour, + day_of_week=day_of_week, + day_of_month=day_of_month, + ) + + # updating periodic task if schedule + periodic_task = None + if schedule: + if PeriodicTask.objects.filter(id=schedule.periodic_task_id).exists(): + # update existing task + periodic_task = PeriodicTask.objects.filter(id=schedule.periodic_task_id) + + # grabbing task_id + arguments['task_id'] = str(periodic_task[0].id) + + # updating task with args + periodic_task.update( + crontab=crontab, + name=task_name, + task=task, + kwargs=json.dumps(arguments), + ) + # get periodic task by id + periodic_task = PeriodicTask.objects.get(id=schedule.periodic_task_id) + + # check if no task yet + if not periodic_task: + + # check if task exists + if PeriodicTask.objects.filter(name=task_name).exists(): + data = {'reason': 'Schedule already exists', 'code': '401'} + if request: + record_api_call(request, data, '401') + return Response(data, status=status.HTTP_401_UNAUTHORIZED) + return data + + # create new periodic task + periodic_task = PeriodicTask.objects.create( + crontab=crontab, + name=task_name, + task=task, + ) + + # inserting task_id + arguments['task_id'] = str(periodic_task.id) + + # updating args + periodic_task.kwargs = json.dumps(arguments) + periodic_task.save() + + # building extras for scheduls + extras = { + "configs": configs, + "type": types, + "case_id": case_id, + "flow_id": flow_id, + "updates": updates, + "threshold": threshold, + } + + # update existing schedule + if schedule: + + # update each param if passed + if timezone: + schedule.timezone = timezone + if begin_date: + schedule.begin_date = begin_date + if time: + schedule.time = time + if freq: + schedule.frequency = freq + if task: + schedule.task = task + if crontab: + schedule.crontab_id = crontab.id + if task_type: + schedule.task_type = task_type + if extras: + schedule.extras = extras + if resources is not None: + schedule.resources = resources + + # save udpdates + schedule.save() + + # create new schedule + if not schedule: + schedule = Schedule.objects.create( + user=request.user, + scope=scope, + resources=resources, + task_type=task_type, + timezone=timezone, + begin_date=begin_date, + time=time, + frequency=freq, + task=task, + crontab_id=crontab.id, + periodic_task_id=periodic_task.id, + extras=extras, + account=account + ) + + # updated accounts usage + account.usage['schedules'] += 1 + account.save() + + # deciding on response type + if request: + # serialize and return + serializer_context = {'request': request,} + data = ScheduleSerializer(schedule, context=serializer_context).data + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + + # return object response + data = { + 'success': True, + 'schedule': schedule, + } + return data + + + + +def update_many_schedules(request: object=None) -> object: + """ + Updates many `Schedules` passed in a list + + Expects: { + 'ids' : list + 'updates' : dict + } + + Returns -> HTTP Response object + """ + + # get request data + ids = request.data.get('ids') + updates = request.data.get('updates') + member = Member.objects.get(user=request.user) + account = member.account + + # set defaults + num_succeeded = 0 + succeeded = [] + num_failed = 0 + failed = [] + this_status = True + + # loop through ids and update + for id in ids: + # reformat update data + data = updates + data['schedule_id'] = str(id) + data['user_id'] = str(request.user.id) + + # send update + try: + data = create_or_update_schedule(**data) + if data.get('reason'): + raise Exception(data['reason']) + # add to success attempts + num_succeeded += 1 + succeeded.append(str(id)) + + except Exception as e: + print(e) + if str(id) not in failed: + num_failed += 1 + this_status = False + failed.append(str(id)) + + # format and return + data = { + 'success': this_status, + 'num_succeeded': num_succeeded, + 'succeeded': succeeded, + 'num_failed': num_failed, + 'failed': failed, + } + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def run_schedule(request: object=None) -> object: + """ + Grabs all the args from the asociated perodic_task + and executes the task manually without interupting + the perodic_task's normal cycle. + + Expects: { + requests: object + } + + Return -> HTTP Response object + """ + + # get request data + schedule_id = request.data.get('schedule_id') + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='schedule', + action='get', id=schedule_id, id_type='schedule' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get schedule and assocated task if checks passed + schedule = Schedule.objects.get(id=schedule_id) + task = schedule.task_type + perodic_task = PeriodicTask.objects.get(id=schedule.periodic_task_id) + task_kwargs = json.loads(perodic_task.kwargs) + + # check location + local = schedule.extras['configs'].get('location', settings.LOCATION) + location_data = check_location(request, local) + if location_data['routed']: + return location_data['response'] + + # decidign on which task + if task == 'scan': + # run create_scan_bg + create_scan_bg.delay( + **task_kwargs + ) + if task == 'test': + # run create_test_bg + create_test_bg.delay( + **task_kwargs + ) + if task == 'caserun': + # run create_caserun_bg + create_caserun_bg.delay( + **task_kwargs + ) + if task == 'flowrun': + # run create_flowrun_bg + create_flowrun_bg.delay( + **task_kwargs + ) + if task == 'report': + # run create_report_bg + create_report_bg.delay( + **task_kwargs + ) + + # serialize and return + serializer_context = {'request': request,} + data = ScheduleSerializer(schedule, context=serializer_context).data + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +def get_schedules(request: object=None) -> object: + """ + Get one or more `Schedules`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + schedule_id = request.query_params.get('schedule_id') + scope = request.query_params.get('scope') + resource_id = request.query_params.get('resource_id') + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # setting default + schedules = None + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='schedule', + action='get', id=schedule_id, id_type='schedule' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single schedule + if schedule_id: + + # get schedule + schedule = Schedule.objects.get(id=schedule_id) + + # serialize and return + serializer_context = {'request': request,} + serialized = ScheduleSerializer(schedule, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + # get all account scoped schedules + if scope == 'account': + schedules = Schedule.objects.filter( + account=account, + scope='account' + ).order_by('-time_created') + + # get all non account scoped + if scope != 'account' and resource_id is None: + schedules = Schedule.objects.filter( + account=account, + scope=scope + ).order_by('-time_created') + + # get all non account scoped schedules with resource_id + if scope != 'account' and resource_id: + schedules = Schedule.objects.filter( + account=account, + resources__icontains=resource_id, + scope=scope + ).order_by('-time_created') + + # serialize and return + paginator = LimitOffsetPagination() + result_page = paginator.paginate_queryset(schedules, request) + serializer_context = {'request': request,} + serialized = ScheduleSerializer(result_page, many=True, context=serializer_context) + response = paginator.get_paginated_response(serialized.data) + record_api_call(request, response.data, '200') + return response + + + + +def get_schedule(request: object=None, id: str=None) -> object: + """ + Get single `Schedule` from the passed "id" + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='schedule', + action='get', id=id, id_type='schedule' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get schedule if checks passed + schedule = Schedule.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = ScheduleSerializer(schedule, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def delete_schedule(request: object=None, id: str=None, user: object=None) -> object: + """ + Deletes the `Schedule` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str, + 'user' : object + } + + Returns -> HTTP Response object + """ + + # get user and account info + if request: + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='schedule', + action='delete', id=id, id_type='schedule' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get schedule and task if checks passed + schedule = Schedule.objects.get(id=id) + task = PeriodicTask.objects.get(id=schedule.periodic_task_id) + + # delete schedule + schedule.delete() + + # delete task + task.delete() + + # decrement resource + decrement_resource(account=account, resource='schedules') + + # return response + data = {'message': 'Schedule has been deleted',} + if request: + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + return data + + + + +def delete_many_schedules(request: object=None) -> object: + """ + Deletes many `Schedules` passed in a list + + Expects: { + 'ids': list + } + + Returns -> HTTP Response object + """ + + # get request data + ids = request.data.get('ids') + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # set defaults + num_succeeded = 0 + succeeded = [] + num_failed = 0 + failed = [] + this_status = True + + # loop through ids and delete + for id in ids: + + # trying to delete schedule + try: + # delete issue and all assocaited resourses + data = delete_schedule(id=id, user=user) + if data.get('reason'): + raise Exception + + # add to success attempts + num_succeeded += 1 + succeeded.append(str(id)) + except Exception as e: + # add to failed attempts + print(e) + num_failed += 1 + failed.append(str(id)) + this_status = False + + # format and return + data = { + 'success': this_status, + 'num_succeeded': num_succeeded, + 'succeeded': succeeded, + 'num_failed': num_failed, + 'failed': failed, + } + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def delete_tasks_and_schedules( + resource_id : str=None, + scope : object=None, + account : object=None + ) -> None: + """ + Helper function to delete any `Schedules` & `PerodicTasks` + associated with the passed "resource_id", "scope", and + "account" + + Expects: { + 'resource_id' : str, + 'scope' : str + 'account' : object + } + + Returns -> None + """ + # get all scopped Schedules + schedules = Schedule.objects.filter( + resources__icontains=resource_id, + account=account, + scope=scope + ) + + # remove any associated tasks + for schedule in schedules: + task = PeriodicTask.objects.get(id=schedule.periodic_task_id) + try: + task.delete() + except Exception as e: + print(e) + + # delete Schedules + schedules.delete() + + return None + + + + +### ------ Begin Alert Services ------ ### + + + + +def create_or_update_alert(request: object=None) -> object: + """ + Creates or Updates an `Alert` + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + actions = request.data.get('actions') + schedule_id = request.data.get('schedule_id') + alert_id = request.data.get('alert_id') + name = request.data.get('name') + expressions = request.data.get('expressions') + + # set defaults + alert = None + schedule = None + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # deciding on recsource + id = alert_id if alert_id else schedule_id + id_type = 'alert' if alert_id else 'schedule' + action = 'add' if schedule_id else 'update' + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='alert', + action=action, id=id, id_type=id_type, + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get schedule if checks passed + if schedule_id: + schedule = Schedule.objects.get(id=schedule_id) + if alert_id: + alert = Alert.objects.get(id=alert_id) + schedule = alert.schedule + + # update existing alert + if alert: + if name: + alert.name = name + if expressions: + alert.expressions = expressions + if actions: + alert.actions = actions + if schedule: + alert.schedule = schedule + # save updates + alert.save() + + # create new alert + if not alert: + alert = Alert.objects.create( + name=name, + expressions=expressions, + actions=actions, + schedule=schedule, + user=user, + account=account + ) + + # update schedule + if schedule: + + # update schedule with new alert + schedule.alert = alert + schedule.save() + + # update associated periodicTask + task = PeriodicTask.objects.get(id=schedule.periodic_task_id) + + # update periodic task + arguments = { + 'scope': json.loads(task.kwargs).get('scope'), + 'resources': json.loads(task.kwargs).get('resources'), + 'account_id': json.loads(task.kwargs).get('account_id'), + 'alert_id': str(alert.id), + 'configs': json.loads(task.kwargs).get('configs'), + 'type': json.loads(task.kwargs).get('type'), + 'threshold': json.loads(task.kwargs).get('threshold'), + 'case_id': json.loads(task.kwargs).get('case_id'), + 'flow_id': json.loads(task.kwargs).get('flow_id'), + 'updates': json.loads(task.kwargs).get('updates'), + 'task_id': json.loads(task.kwargs).get('task_id'), + } + task.kwargs=json.dumps(arguments) + task.save() + + # serialize and return + serializer_context = {'request': request,} + data = AlertSerializer(alert, context=serializer_context).data + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +def get_alerts(request: object=None) -> object: + """ + Get one or more `Alerts`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + alert_id = request.query_params.get('alert_id') + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='alert', + action='get', id=alert_id, id_type='alert' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single alert + if alert_id: + + # get alert + alert = Alert.objects.get(id=alert_id) + + # serialize and return + serializer_context = {'request': request,} + serialized = AlertSerializer(alert, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + # get all alerts associated with account + alerts = Alert.objects.filter(account=account).order_by('-time_created') + + # serialize and return + paginator = LimitOffsetPagination() + result_page = paginator.paginate_queryset(alerts, request) + serializer_context = {'request': request,} + serialized = AlertSerializer(result_page, many=True, context=serializer_context) + response = paginator.get_paginated_response(serialized.data) + record_api_call(request, response.data, '200') + return response + + + + +def get_alert(request: object=None, id: str=None) -> object: + """ + Get single `Alert` from the passed "id" + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='alert', + action='get', id=id, id_type='alert' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get alert if checks passed + alert = Alert.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = AlertSerializer(alert, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def delete_alert(request: object=None, id: str=None) -> object: + """ + Deletes the `Alert` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account info + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='alert', + action='delete', id=id, id_type='alert' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get alert if checks passed + alert = Alert.objects.get(id=id) + + # delete alert + alert.delete() + + # return response + data = {'message': 'Alert has been deleted',} + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +### ------ Begin Report Services ------ ### + + + + +def create_or_update_report(request: object=None) -> object: + """ + Creates or Updates an `Report` + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + report_id = request.data.get('report_id') + page_id = request.data.get('page_id') + report_type = request.data.get('type', ['lighthouse', 'yellowlab']) + text_color = request.data.get('text_color', '#24262d') + background_color = request.data.get('background_color', '#e1effd') + highlight_color = request.data.get('highlight_color', '#4283f8') + + # set defaults + report = None + page = None + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + id = report_id if report_id else page_id + id_type = 'report' if report_id else 'page' + action = 'update' if report_id else 'add' + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='report', + action=action, id=id, id_type=id_type + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get page if checks passed + if page_id: + page = Page.objects.get(id=page_id) + # get report if checks passed + if report_id: + report = Report.objects.get(id=report_id) + + # build report info + info = { + "text_color": text_color, + "background_color": background_color, + "highlight_color": highlight_color, + } + + # update report + if report: + if info: + report.info = info + if report_type: + report.type = report_type + # save updates + report.save() + + # create new report + if not report: + report = Report.objects.create( + user=request.user, + page=page, + site=page.site, + account=account, + info=info, + type=report_type + ) + + # get uncached report + un_cached_report = Report.objects.get(id=report.id) + + # generate report + report_data = R(report=un_cached_report).generate_report() + + # serialize report + serializer_context = {'request': request,} + new_report = ReportSerializer( + report_data['report'], + context=serializer_context + ).data + + # format return data + data = { + 'report': new_report, + 'success': report_data['success'], + 'message': report_data['message'] + } + + # serialize and return + record_api_call(request, data, '201') + response = Response(data, status=status.HTTP_201_CREATED) + return response + + + + +def get_reports(request: object=None) -> object: + """ + Get one or more `Reports`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + page_id = request.query_params.get('page_id') + report_id = request.query_params.get('report_id') + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + id = report_id if report_id else page_id + id_type = 'report' if report_id else 'page' + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='report', + action='add', id=id, id_type=id_type + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single report + if report_id: + + # get report + report = Report.objects.get(id=report_id) + + # serialize and return + serializer_context = {'request': request,} + serialized = ReportSerializer(report, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + # get reports scoped to page if checks passed + if page_id: + page = Page.objects.get(id=page_id) + reports = Report.objects.filter(page=page, account=account).order_by('-time_created') + + # get reports scoped to user if checks passed + if page_id is None and report_id is None: + reports = Report.objects.filter(user=request.user).order_by('-time_created') + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + reports = reports.filter(site__id__in=id_list).order_by('-time_created') + + # serialize and return + paginator = LimitOffsetPagination() + result_page = paginator.paginate_queryset(reports, request) + serializer_context = {'request': request,} + serialized = ReportSerializer(result_page, many=True, context=serializer_context) + response = paginator.get_paginated_response(serialized.data) + record_api_call(request, response.data, '200') + return response + + + + +def get_report(request: object=None, id: str=None) -> object: + """ + Get single `Report` from the passed "id" + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='report', + action='get', id=id, id_type='report' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get report if checks passed + report = Report.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = ReportSerializer(report, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def delete_report(request: object=None, id: str=None) -> object: + """ + Deletes the `Report` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account info + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='report', + action='delete', id=id, id_type='report' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get report if checks passed + report = Report.objects.get(id=id) + + # remove s3 objects + delete_report_s3_bg.delay(report_id=id) + + # remove report + report.delete() + + # return reponse + data = {'message': 'Report has been deleted',} + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +def export_report(request: object=None) -> object: + """ + Used to create and send a Cursion.landing + `Report` to the passed "email" + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # getting data from request + report_id = request.data.get('report_id') + email = request.data.get('email') + first_name = request.data.get('first_name') + + # send task to background + create_report_export_bg.delay( + report_id=report_id, + email=email, + first_name=first_name + ) + + # building response + data = { + 'success': True, + 'error': None + } + + # returning response + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +### ------ Begin Cases Services ------ ### + + + + +def create_or_update_case(request: object=None) -> object: + """ + Creates or Updates a `Case` + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + case_id = request.data.get('case_id') + steps = request.data.get('steps') + site_url = request.data.get('site_url') + site_id = request.data.get('site_id') + title = request.data.get('title') + tags = request.data.get('tags') + _type = request.data.get('type') + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # setting defaults + site = None + case = None + action = 'update' if case_id else 'add' + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='case', + action=action, id=case_id, id_type='case' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get site if site_url passed + if site_url: + if Site.objects.filter(account=account, site_url=site_url).exists(): + site = Site.objects.filter(account=account, site_url=site_url)[0] + + # get site if site_id passed + if site_id: + if Site.objects.filter(account=account, id=site_id).exists(): + site = Site.objects.get(id=site_id) + site_url = site.site_url + + # check for no site and no case_id + if not site and not case_id: + data = {'reason': 'site not found'} + record_api_call(request, data, '404') + response = Response(data, status=status.HTTP_404_NOT_FOUND) + + # get case if checks passed + if case_id: + case = Case.objects.get(id=case_id) + + # update Case + if case: + if steps is not None: + steps_data = save_case_steps(steps, case_id) + case.steps = steps_data + if title is not None: + case.title = title + if tags is not None: + case.tags = tags + if site is not None: + case.site = site + if site_url is not None: + case.site_url = site_url + # save updates + case.save() + + # create Case + if not case: + + # generate new uuid + case_id = uuid.uuid4() + + # save step data in s3 + steps_data = save_case_steps(steps, case_id) + + # create new Case + case = Case.objects.create( + id = case_id, + user = user, + account = account, + title = title, + type = _type if _type is not None else "recorded", + site = site, + site_url = site_url, + steps = steps_data, + + ) + + # create process obj + process = Process.objects.create( + site=site, + type='case.pre_run', + object_id=str(case.id), + account=account, + progress=1 + ) + + # start pre_run for new Case + case_pre_run_bg.delay( + case_id=str(case.id), + process_id=str(process.id) + ) + + # serialize and return + serializer_context = {'request': request,} + data = CaseSerializer(case, context=serializer_context).data + record_api_call(request, data, '201') + response = Response(data, status=status.HTTP_201_CREATED) + return response + + + + +def save_case_steps(steps: dict, case_id: str) -> dict: + """ + Helper function that uploads the "steps" data to + s3 bucket + + Expects: { + 'steps' : dict, + 'case_id' : str + } + + Returns -> data: { + 'num_steps' : int, + 'url' : str + } + """ + + # setup boto3 configurations + s3 = boto3.client( + 's3', aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), + aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), + region_name=str(settings.AWS_S3_REGION_NAME), + endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) + ) + + # saving as json file temporarily + steps_id = uuid.uuid4() + with open(f'{steps_id}.json', 'w') as fp: + json.dump(steps, fp) + + # seting up paths + steps_file = os.path.join(settings.BASE_DIR, f'{steps_id}.json') + remote_path = f'static/cases/{case_id}/{steps_id}.json' + root_path = settings.AWS_S3_URL_PATH + steps_url = f'{root_path}/{remote_path}' + + # upload to s3 + with open(steps_file, 'rb') as data: + s3.upload_fileobj(data, str(settings.AWS_STORAGE_BUCKET_NAME), + remote_path, ExtraArgs={ + 'ACL': 'public-read', + 'ContentType': 'application/json', + 'CacheControl': 'max-age=0' + } + ) + + # remove local copy + os.remove(steps_file) + + # format data + data = { + 'num_steps': len(steps), + 'url': steps_url + } + + # return response + return data + + + + +def get_cases(request: object=None) -> object: + """ + Get one or more `Cases`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + case_id = request.query_params.get('case_id') + site_id = request.query_params.get('site_id') + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # setting defaulta + case = None + site = None + id = case_id if case_id else site_id + id_type = 'case' if case_id else 'site' + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='case', + action='get', id=id, id_type=id_type + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single case + if case_id: + + # get case + case = Case.objects.get(id=case_id) + + # serialize and return + serializer_context = {'request': request,} + serialized = CaseSerializer(case, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + # get site if checks passed + if site_id: + site = Site.objects.get(id=site_id) + + # get cases scoped by site + if site: + cases = Case.objects.filter(account=account, site=site).order_by('-time_created') + + # get cases scoped by account + if not site: + cases = Case.objects.filter(account=account).order_by('-time_created') + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + cases = cases.filter(site__id__in=id_list).order_by('-time_created') + + # serialize and return + paginator = LimitOffsetPagination() + result_page = paginator.paginate_queryset(cases, request) + serializer_context = {'request': request,} + serialized = CaseSerializer(result_page, many=True, context=serializer_context) + response = paginator.get_paginated_response(serialized.data) + record_api_call(request, response.data, '200') + return response + + + + +def get_case(request: object=None, id: str=None) -> object: + """ + Get single `Case` from the passed "id" + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='case', + action='get', id=id, id_type='case' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get case if checks passed + case = Case.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = CaseSerializer(case, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def search_cases(request: object=None) -> object: + """ + Searches for matching `Cases` to the passed + "query" + + Expects: { + 'request': obejct + } + + Returns -> HTTP Response object + """ + + # get request data + user = request.user + member = Member.objects.get(user=user) + account = member.account + query = request.query_params.get('query') + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='case', action='get' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # search for cases + cases = Case.objects.filter( + Q(account=account, title__icontains=query) | + Q(account=account, site_url__icontains=query) + ).order_by('-time_created') + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + cases = cases.filter(site__id__in=id_list).order_by('-time_created') + + # serialize and rerturn + paginator = LimitOffsetPagination() + result_page = paginator.paginate_queryset(cases, request) + serializer_context = {'request': request,} + serialized = CaseSerializer(result_page, many=True, context=serializer_context) + response = paginator.get_paginated_response(serialized.data) + record_api_call(request, response.data, '200') + return response + + + + +def create_auto_cases(request: object=None) -> object: + """ + Initiates a new `Case` generation task for the `Site` + associated with either the passed "site_url" or "site_id" + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # check location + location_data = check_location(request, None) + if location_data['routed']: + return location_data['response'] + + # get request data + site_id = request.data.get('site_id') + site_url = request.data.get('site_url') + start_url = request.data.get('start_url') + max_cases = request.data.get('max_cases', 4) + max_layers = request.data.get('max_layers', 6) + configs = request.data.get('configs', None) + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # updating configs if None: + configs = account.configs if configs == None else configs + + # get site if only site_url present + if site_url is not None: + site = Site.objects.filter(account=account, site_url=site_url)[0] + site_id = str(site.id) + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='case', + action='add', id=site_id, id_type='site' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get site if only site_id present + if site_id and not site_url: + site = Site.objects.get(id=site_id) + + # create process obj + process = Process.objects.create( + site=site, + type='case.generate', + account=account, + progress=1 + ) + + # send data to bg_autocase_task + create_auto_cases_bg.delay( + site_id=site_id, + process_id=process.id, + start_url=start_url, + configs=configs, + max_cases=max_cases, + max_layers=max_layers, + ) + + # return response + data = { + 'message': 'Cases are generating', + 'process': str(process.id), + } + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +def copy_case(request: object=None) -> object: + """ + Creates a copy of the passed `Case` + + Expects: { + 'request': object + } + + Returns -> HTTP Response obejct + """ + + # get request data + case_id = request.data.get('case_id') + + # get user and acount + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='case', + action='add', id=case_id, id_type='case' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get case if checks passed + if case_id: + case = Case.objects.get(id=case_id, account=account) + + # download steps + steps = requests.get(case.steps['url']).json() + + # save steps as new s3 obj + new_case_id = uuid.uuid4() + steps_data = save_case_steps(steps, new_case_id) + + # create new case + new_case = Case.objects.create( + id = new_case_id, + user = user, + account = account, + title = f'Copy - {case.title}', + type = case.type, + site = case.site, + site_url = case.site_url, + steps = steps_data, + processed = True + + ) + + # return response + serializer_context = {'request': request,} + data = CaseSerializer(new_case, context=serializer_context).data + record_api_call(request, data, '201') + response = Response(data, status=status.HTTP_201_CREATED) + return response + + + + +def delete_case(request: object=None, id: str=None, user: object=None) -> object: + """ + Deletes the `Case` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str, + 'user' : object, + } + + Returns -> HTTP Response object + """ + + # get user and account info + if request: + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='case', + action='delete', id=id, id_type='case' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get case if checks passed + case = Case.objects.get(id=id) + + # delete case s3 objects + delete_case_s3_bg.delay(case_id=id) + + # delete case + case.delete() + + # return response + data = {'message': 'Case has been deleted',} + if request: + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + return data + + + + +def delete_many_cases(request: object=None) -> object: + """ + Deletes many `Cases` passed in a list + + Expects: { + 'ids': list + } + + Returns -> HTTP Response object + """ + + # get request data + ids = request.data.get('ids') + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # set defaults + num_succeeded = 0 + succeeded = [] + num_failed = 0 + failed = [] + this_status = True + + # loop through ids and delete + for id in ids: + + # trying to delete case + try: + # delete case and all assocaited resourses + data = delete_case(id=id, user=user) + if data.get('reason'): + raise Exception + + # add to success attempts + num_succeeded += 1 + succeeded.append(str(id)) + except Exception as e: + # add to failed attempts + print(e) + num_failed += 1 + failed.append(str(id)) + this_status = False + + # format and return + data = { + 'success': this_status, + 'num_succeeded': num_succeeded, + 'succeeded': succeeded, + 'num_failed': num_failed, + 'failed': failed, + } + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def get_cases_zapier(request: object=None) -> object: + """ + Get all `Cases` associated with user's Account. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + user = request.user + member = Member.objects.get(user=user) + account = member.account + site_id = request.query_params.get('site_id') + cases = None + + # deciding on scope + resource = 'case' + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource=resource, + action='get', id=site_id, id_type='site' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + return Response(data, status=check_data['status']) + + # get all site_id associated cases + if site_id: + site = Site.objects.get(id=site_id) + cases = Case.objects.filter( + account=account, + site=site + ).order_by('-time_created') + + # get all account assocoiated cases + if cases is None: + cases = Case.objects.filter( + account=account, + ).order_by('-time_created') + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + cases = cases.filter(site__id__in=id_list).order_by('-time_created') + + # build response data + data = [] + + for case in cases: + data.append({ + 'id' : str(case.id), + 'title' : case.title, + 'time_created' : str(case.time_created), + 'site' : str(case.site.id), + 'site_url' : case.site_url, + 'steps' : case.steps, + 'tags' : case.tags + }) + + # serialize and return + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +### ------ Begin CaseRun Services ------ ### + + + + +def create_caserun(request: object=None) -> object: + """ + Creates a new `CaseRun` from the passed "case_id" for the + passed "site_id" + + Expects: { + 'request': obejct + } + + Returns -> HTTP Response object + """ + + # check location + location_data = check_location(request, None) + if location_data['routed']: + return location_data['response'] + + # get request data + case_id = request.data.get('case_id') + site_id = request.data.get('site_id') + updates = request.data.get('updates') + configs = request.data.get('configs', None) + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # updating configs if None: + configs = account.configs if configs == None else configs + + # check site + if not Site.objects.filter(id=site_id, account=account).exists(): + data = {'reason': 'site not found'} + record_api_call(request, data, '404') + return Response(data, status=status.HTTP_404_NOT_FOUND) + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='caserun', + action='add', id=case_id, id_type='case' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get case & site if checks passed + case = Case.objects.get(id=case_id, account=account) + site = Site.objects.get(id=site_id, account=account) + + # getting steps from case + steps = requests.get(case.steps['url']).json() + + # adding new info to steps for caserun + for step in steps: + # expanding action + if step['action']['type'] != None: + step['action']['time_created'] = None + step['action']['time_completed'] = None + step['action']['exception'] = None + step['action']['status'] = None + step['action']['img'] = None + # expanding assertion + if step['assertion']['type'] != None: + step['assertion']['time_created'] = None + step['assertion']['time_completed'] = None + step['assertion']['exception'] = None + step['assertion']['status'] = None + + # updating values if requested + if updates != None: + for update in updates: + steps[int(update['index'])]['action']['value'] = update['value'] + + # increment account.usage.caserun + account.usage['caseruns'] += 1 + account.save() + + # create new tescase + caserun = CaseRun.objects.create( + case = case, + title = case.title, + site = site, + user = request.user, + configs = configs, + steps = steps, + account = account + ) + + # pass the newly created CaseRun to the backgroud task to run + run_case.delay(caserun_id=caserun.id) + + # serialize and return + data = { + 'id': str(caserun.id), + 'title': str(caserun.title), + 'site': str(site.id), + 'time_created': str(caserun.time_created) + } + record_api_call(request, data, '201') + response = Response(data, status=status.HTTP_201_CREATED) + return response + + + + +def get_caseruns(request: object=None) -> object: + """ + Get one or more `CaseRun`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + caserun_id = request.query_params.get('caserun_id') + site_id = request.query_params.get('site_id') + lean = request.query_params.get('lean') + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # defaults + id = caserun_id if caserun_id else site_id + id_type = 'caserun' if caserun_id else 'site' + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='caserun', + action='get', id=id, id_type=id_type + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single caserun + if caserun_id: + + # get caserun + caserun = CaseRun.objects.get(id=caserun_id) + + # serialize and return + serializer_context = {'request': request,} + serialized = CaseRunSerializer(caserun, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + # get caseruns scoped to site + if site_id: + site = Site.objects.get(id=site_id, account=account) + caseruns = CaseRun.objects.filter(site=site).order_by('-time_created') + + # get caseruns scoped to account + if not site_id: + caseruns = CaseRun.objects.filter(account=account).order_by('-time_created') + + # serialize and return + paginator = LimitOffsetPagination() + result_page = paginator.paginate_queryset(caseruns, request) + serializer_context = {'request': request,} + serialized = CaseRunSerializer(result_page, many=True, context=serializer_context) + if str(lean).lower() == 'true': + serialized = SmallCaseRunSerializer(result_page, many=True, context=serializer_context) + response = paginator.get_paginated_response(serialized.data) + record_api_call(request, response.data, '200') + return response + + + + +def get_caserun(request: object=None, id: str=None) -> object: + """ + Get single `CaseRun` from the passed "id" + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='caserun', + action='get', id=id, id_type='caserun' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get caserun if checks passed + caserun = CaseRun.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = CaseRunSerializer(caserun, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def delete_caserun(request: object=None, id: str=None, user: object=None) -> object: + """ + Deletes the `CaseRun` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str, + 'user' : object + } + + Returns -> HTTP Response object + """ + + # get user and account info + if request: + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='caserun', + action='delete', id=id, id_type='caserun' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get caserun if checks passed + caserun = CaseRun.objects.get(id=id) + + # remove s3 objects + delete_caserun_s3_bg.delay(caserun_id=id) + + # delete caserun + caserun.delete() + + # return response + data = {'message': 'CaseRun has been deleted',} + if request: + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + return data + + + + +def get_caseruns_zapier(request: object=None) -> object: + """ + Get all `CaseRuns` associated with user's Account. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + _status = request.query_params.get('status') + user = request.user + member = Member.objects.get(user=user) + account = member.account + caseruns = None + + # deciding on scope + resource = 'caserun' + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource=resource, + action='get', + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + return Response(data, status=check_data['status']) + + # get all account assocoiated caseruns + if caseruns is None: + caseruns = CaseRun.objects.filter( + account=account, + ).exclude( + time_completed=None, + ).order_by('-time_created') + + # filter by _status if requested + if _status is not None: + caseruns = caseruns.filter(status=_status) + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + caseruns = caseruns.filter(site__id__in=id_list).order_by('-time_created') + + # build response data + data = [] + + for caserun in caseruns: + data.append({ + 'id' : str(caserun.id), + 'case' : str(caserun.case.id), + 'title' : str(caserun.title), + 'site' : str(caserun.site.id), + 'time_created' : str(caserun.time_created), + 'time_completed' : str(caserun.time_completed), + 'configs' : caserun.configs, + 'status' : str(caserun.status), + }) + + # serialize and return + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +### ------ Begin Flow Services ------ ### + + + + +def create_or_update_flow(request: object=None) -> object: + """ + Creates or Updates a `Flow` + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + flow_id = request.data.get('flow_id') + nodes = request.data.get('nodes') + edges = request.data.get('edges') + title = request.data.get('title') + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # setting defaults + flow = None + action = 'update' if flow_id else 'add' + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='flow', + action=action, id=flow_id, id_type='flow' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get flow if checks passed + if flow_id: + flow = Flow.objects.get(id=flow_id) + + # update flow + if flow: + if title is not None: + flow.title = title + if nodes is not None: + flow.nodes = nodes + if edges is not None: + flow.edges = edges + # save updates + flow.save() + + # create Case + if not flow: + + # create new Flow + flow = Flow.objects.create( + user = request.user, + account = account, + title = title if title is not None else 'Untitled Flow', + ) + + # serialize and return + serializer_context = {'request': request,} + data = FlowSerializer(flow, context=serializer_context).data + record_api_call(request, data, '201') + response = Response(data, status=status.HTTP_201_CREATED) + return response + + + + +def get_flows(request: object=None) -> object: + """ + Get one or more `Flows`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + flow_id = request.query_params.get('flow_id') + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # setting default + flow = None + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='flow', + action='get', id=flow_id, id_type='flow' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single flow + if flow_id: + + # get flow + flow = Flow.objects.get(id=flow_id) + + # serialize and return + serializer_context = {'request': request,} + serialized = FlowSerializer(flow, context=serializer_context) data = serialized.data record_api_call(request, data, '200') return Response(data, status=status.HTTP_200_OK) + + # get flows scoped by account + flows = Flow.objects.filter(account=account).order_by('-time_created') + + # serialize and return + paginator = LimitOffsetPagination() + result_page = paginator.paginate_queryset(flows, request) + serializer_context = {'request': request,} + serialized = FlowSerializer(result_page, many=True, context=serializer_context) + response = paginator.get_paginated_response(serialized.data) + record_api_call(request, response.data, '200') + return response + + + + +def get_flow(request: object=None, id: str=None) -> object: + """ + Get single `Flow` from the passed "id" + + Expects: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='flow', + action='get', id=id, id_type='flow' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get flow if checks passed + flow = Flow.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = FlowSerializer(flow, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def search_flows(request: object=None) -> object: + """ + Searches for matching `Flows` to the passed + "query" + + Expects: { + 'request': obejct + } + + Returns -> HTTP Response object + """ + + # get request data + user = request.user + member = Member.objects.get(user=user) + account = member.account + query = request.query_params.get('query') + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='flow', + action='get' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # search for flows + flows = Flow.objects.filter( + Q(account=account, title__icontains=query) + ).order_by('-time_created') - automations = Automation.objects.filter(user=user).order_by('-time_created') + # serialize and rerturn paginator = LimitOffsetPagination() - result_page = paginator.paginate_queryset(automations, request) + result_page = paginator.paginate_queryset(flows, request) serializer_context = {'request': request,} - serialized = AutomationSerializer(result_page, many=True, context=serializer_context) + serialized = FlowSerializer(result_page, many=True, context=serializer_context) response = paginator.get_paginated_response(serialized.data) record_api_call(request, response.data, '200') - return response + return response -def delete_automation(request, id): - user = request.user - account = Member.objects.get(user=user).account - try: - automation = Automation.objects.get(id=id) - except: - data = {'reason': 'cannot find a Automation with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) +def copy_flow(request: object=None) -> object: + """ + Creates a copy of the passed `Flow` + + Expects: { + 'request': object + } - if automation.account != account: - data = {'reason': 'delete an automation you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + Returns -> HTTP Response obejct + """ - automation.delete() + # get request data + flow_id = request.data.get('flow_id') - data = {'message': 'Automation has been deleted',} - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) + # get user and acount + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='flow', + action='add', id=flow_id, id_type='flow' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get flow if checks passed + if flow_id: + flow = Flow.objects.get(id=flow_id, account=account) + + # create new flow + new_flow = Flow.objects.create( + user = request.user, + account = account, + title = f'Copy - {flow.title}', + nodes = flow.nodes, + edges = flow.edges + ) + + # return response + serializer_context = {'request': request,} + data = FlowSerializer(new_flow, context=serializer_context).data + record_api_call(request, data, '201') + response = Response(data, status=status.HTTP_201_CREATED) return response + + +def delete_flow(request: object=None, id: str=None, user: object=None) -> object: + """ + Deletes the `Flow` associated with the passed "id" + Expcets: { + 'request' : object, + 'id' : str, + 'user' : object, + } + Returns -> HTTP Response object + """ + # get user and account info + if request: + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='flow', + action='delete', id=id, id_type='flow' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get flow if checks passed + flow = Flow.objects.get(id=id) + + # delete flow + flow.delete() + + # return response + data = {'message': 'Flow has been deleted',} + if request: + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + return data -def create_or_update_report(request): +def delete_many_flows(request: object=None) -> object: + """ + Deletes many `Flows` passed in a list + Expects: { + 'ids': list + } + + Returns -> HTTP Response object + """ + + # get request data + ids = request.data.get('ids') user = request.user - account = Member.objects.get(user=user).account + member = Member.objects.get(user=user) + account = member.account - report_id = request.data.get('report_id', None) - site_id = request.data.get('site_id', None) - report_type = request.data.get('type', ['lighthouse', 'yellowlab']) - text_color = request.data.get('text_color', '#24262d') - background_color = request.data.get('background_color', '#e1effd') - highlight_color = request.data.get('highlight_color', '#4283f8') - site = Site.objects.get(id=site_id) + # set defaults + num_succeeded = 0 + succeeded = [] + num_failed = 0 + failed = [] + this_status = True - info = { - "text_color": text_color, - "background_color": background_color, - "highlight_color": highlight_color, + # loop through ids and delete + for id in ids: + + # trying to delete flow + try: + # delete flow and all assocaited resourses + data = delete_flow(id=id, user=user) + if data.get('reason'): + raise Exception + + # add to success attempts + num_succeeded += 1 + succeeded.append(str(id)) + except Exception as e: + # add to failed attempts + print(e) + num_failed += 1 + failed.append(str(id)) + this_status = False + + # format and return + data = { + 'success': this_status, + 'num_succeeded': num_succeeded, + 'succeeded': succeeded, + 'num_failed': num_failed, + 'failed': failed, + } + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + + + +def get_flows_zapier(request: object=None) -> object: + """ + Get all `Flows` associated with user's Account. + + Expects: { + 'request': object } - if report_id: - try: - report = Report.objects.get(id=report_id) - except: - data = {'reason': 'cannot find a Report with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) + Returns -> HTTP Response object + """ - if report.account != account: - data = {'reason': 'update a Report you do not own'} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + # get request data + user = request.user + member = Member.objects.get(user=user) + account = member.account + flows = None - else: - report = Report.objects.create( - user=request.user, site=site, - account=account - ) + # deciding on scope + resource = 'flow' - # update report data - report.info = info - report.type = report_type - report.save() - un_cached_report = Report.objects.get(id=report.id) + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='flow', + action='get', + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + return Response(data, status=check_data['status']) + + # get all account assocoiated flows + if flows is None: + flows = Flow.objects.filter( + account=account, + ).order_by('-time_created') + + # build response data + data = [] + + for flow in flows: + data.append({ + 'id' : str(flow.id), + 'title' : flow.title, + 'time_created' : str(flow.time_created) + }) + + # serialize and return + response = Response(data, status=status.HTTP_200_OK) + return response - # generate report - updated_report = R(report=un_cached_report).make_test_report() - serializer_context = {'request': request,} - data = ReportSerializer(updated_report, context=serializer_context).data +### ------ Begin FlowRun Services ------ ### + + + + +def create_flowrun(request: object=None) -> object: + """ + Creates a new `FlowRun` from the passed + "flow_id" & "site_id" + + Expects: { + 'request': obejct + } + + Returns -> HTTP Response object + """ + + # get request data + flow_id = request.data.get('flow_id') + site_id = request.data.get('site_id') + configs = request.data.get('configs', None) + + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # update configs + configs = account.configs if configs is None else configs + + # check site + if not Site.objects.filter(id=site_id, account=account).exists(): + data = {'reason': 'site not found'} + record_api_call(request, data, '404') + return Response(data, status=status.HTTP_404_NOT_FOUND) + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='flowrun', + action='add', id=flow_id, id_type='flow' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get flow if checks passed + flow = Flow.objects.get(id=flow_id) + + # get site if checks passed + site = Site.objects.get(id=site_id) + + # increment account.usage.runs + account.usage['flowruns'] += 1 + account.save() + + # set flowrun_id + flowrun_id = uuid.uuid4() + + # update nodes + nodes = flow.nodes + for i in range(len(nodes)): + nodes[i]['data']['status'] = 'queued' + nodes[i]['data']['finalized'] = False + nodes[i]['data']['time_started'] = None + nodes[i]['data']['time_completed'] = None + nodes[i]['data']['objects'] = [] + + # updates edges + edges = flow.edges + for i in range(len(edges)): + edges[i]['animated'] = False + edges[i]['style'] = None + + # create init log + logs = [{ + 'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f'), + 'message': f'system starting up for run_id: {str(flowrun_id)}', + 'step': '1' + },] + + # create flowrun + flowrun = FlowRun.objects.create( + id = flowrun_id, + flow = flow, + user = flow.user, + account = flow.account, + site = site, + title = flow.title, + nodes = nodes, + edges = edges, + logs = logs, + configs = configs + ) + + # update flow with time_last_run + flow = Flow.objects.get(id=flow_id) + flow.time_last_run = datetime.now(timezone.utc) + flow.save() + + # signals.py should pick up this `create()` + # event and then run the first instance of flowr.py + + # serialize and return + data = { + 'id': str(flowrun.id), + 'title': str(flowrun.title), + 'site': str(site.id), + 'time_created': str(flowrun.time_created) + } record_api_call(request, data, '201') response = Response(data, status=status.HTTP_201_CREATED) return response @@ -1372,203 +6212,359 @@ def create_or_update_report(request): +def get_flowruns(request: object=None) -> object: + """ + Get one or more `FlowRun`. -def get_reports(request): - site_id = request.query_params.get('site_id', None) - report_id = request.query_params.get('report_id', None) - user = request.user - account = Member.objects.get(user=user).account + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + flowrun_id = request.query_params.get('flowrun_id') + site_id = request.query_params.get('site_id') + lean = request.query_params.get('lean') - if site_id: - try: - site = Site.objects.get(id=site_id) - except: - data = {'reason': 'cannot find a Site with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - reports = Report.objects.filter(site=site, account=account).order_by('-time_created') + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account - if report_id: - try: - report = Report.objects.get(id=report_id) - except: - data = {'reason': 'cannot find a Report with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) + # defaults + id = site_id if site_id else flowrun_id + id_type = 'site' if site_id else 'flowrun' - if site_id is None and report_id is None: - reports = Report.objects.filter(user=request.user).order_by('-time_created') + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='flowrun', + action='get', id=id, id_type=id_type + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single flowrun + if flowrun_id: + + # get flowrun + flowrun = FlowRun.objects.get(id=flowrun_id) + + # serialize and return + serializer_context = {'request': request,} + serialized = FlowRunSerializer(flowrun, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) + + # getting site scoped flowruns + if site_id: + flowruns = FlowRun.objects.filter( + site__id=site_id, + account=account + ).order_by('-time_created') + + # get flowruns scoped to account + if not site_id: + flowruns = FlowRun.objects.filter( + account=account + ).order_by('-time_created') + # serialize and return paginator = LimitOffsetPagination() - result_page = paginator.paginate_queryset(reports, request) + result_page = paginator.paginate_queryset(flowruns, request) serializer_context = {'request': request,} - serialized = ReportSerializer(result_page, many=True, context=serializer_context) + serialized = FlowRunSerializer(result_page, many=True, context=serializer_context) + if str(lean).lower() == 'true': + serialized = SmallFlowRunSerializer(result_page, many=True, context=serializer_context) response = paginator.get_paginated_response(serialized.data) record_api_call(request, response.data, '200') return response - - -def delete_report(request, id): - user = request.user - account = Member.objects.get(user=user).account - try: - report = Report.objects.get(id=id) - except: - data = {'reason': 'cannot find a Report with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) +def get_flowrun(request: object=None, id: str=None) -> object: + """ + Get single `FlowRun` from the passed "id" - if report.account != account: - data = {'reason': 'delete Reports you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + Expects: { + 'request' : object, + 'id' : str + } - # remove s3 objects - delete_report_s3_bg.delay(report_id=id) - - # remove report - report.delete() + Returns -> HTTP Response object + """ - data = {'message': 'Report has been deleted',} + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='flowrun', + action='get', id=id, id_type='flowrun' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get flowruns if checks passed + flowruns = FlowRun.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = FlowRunSerializer(flowruns, context=serializer_context) + data = serialized.data record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response + return Response(data, status=status.HTTP_200_OK) -def get_processes(request): - site_id = request.query_params.get('site_id', None) - process_id = request.query_params.get('process_id', None) +def delete_flowrun(request: object=None, id: str=None, user: object=None) -> object: + """ + Deletes the `FlowRun` associated with the passed "id" - if site_id: - try: - site = Site.objects.get(id=site_id) - except: - data = {'reason': 'cannot find a Site with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - processes = Process.objects.filter(site=site).order_by('-time_created') + Expcets: { + 'request' : object, + 'id' : str, + 'account' : object + } + + Returns -> HTTP Response object + """ + + # get user and account info + if request: + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='flowrun', + action='delete', id=id, id_type='flowrun' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get flowrun if checks passed + flowrun = FlowRun.objects.get(id=id) + + # delete flowrun + flowrun.delete() + + # return response + data = {'message': 'FlowRun has been deleted',} + if request: + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + return data - if process_id: - try: - process = Process.objects.get(id=process_id) - serializer_context = {'request': request,} - data = ProcessSerializer(process, context=serializer_context).data - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response - except: - data = {'reason': 'cannot find a Process with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - if site_id is None and report_id is None: - processes = Process.objects.all().order_by('-time_created') - paginator = LimitOffsetPagination() - result_page = paginator.paginate_queryset(processes, request) - serializer_context = {'request': request,} - serialized = ProcessSerializer(result_page, many=True, context=serializer_context) - response = paginator.get_paginated_response(serialized.data) - record_api_call(request, response.data, '200') - return response +def get_flowruns_zapier(request: object=None) -> object: + """ + Get all `FlowRuns` associated with user's Account. + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + _status = request.query_params.get('status') + member = Member.objects.get(user=request.user) + account = member.account + flowruns = None + + # deciding on scope + resource = 'flowrun' + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='flowrun', + action='get', + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + return Response(data, status=check_data['status']) + + # get all account assocoiated flowruns + if flowruns is None: + flowruns = FlowRun.objects.filter( + account=account, + ).exclude( + time_completed=None, + ).order_by('-time_created') + + # filter by _status if requested + if _status is not None: + flowruns = flowruns.filter(status=_status) + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + flowruns = flowruns.filter(site__id__in=id_list).order_by('-time_created') + + # build response data + data = [] + + for run in flowruns: + data.append({ + 'id' : str(run.id), + 'flow' : str(run.flow.id), + 'site' : str(run.site.id), + 'title' : str(run.title), + 'time_created' : str(run.time_created), + 'time_completed' : str(run.time_completed), + 'status' : str(run.status) + }) + + # serialize and return + response = Response(data, status=status.HTTP_200_OK) + return response +### ------ Begin Secret Services ------ ### +def create_or_update_secret(request: object=None) -> object: + """ + Creates or Updates a `Secret` -def create_or_update_case(request): - case_id = request.data.get('case_id') - steps = request.data.get('steps') + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + secret_id = request.data.get('secret_id') name = request.data.get('name') - tags = request.data.get('tags') + value = request.data.get('value') + action = 'update' if secret_id else 'add' + + # get user & account user = request.user - account = Member.objects.get(user=user).account - - account_is_active = check_account(request) - if not account_is_active: - data = {'reason': 'account not funded',} - record_api_call(request, data, '402') - return Response(data, status=status.HTTP_402_PAYMENT_REQUIRED) + member = Member.objects.get(user=user) + account = member.account + - if case_id: - try: - case = Case.objects.get(id=case_id) - except: - data = {'reason': 'cannot find a Case with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='secret', + action=action, id=secret_id, id_type='secret' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # encrypt value if passed + f = Fernet(settings.SECRETS_KEY) + bytes_value = bytes(value, 'utf-8') + encrypted_value = f.encrypt(bytes_value).decode('utf-8') + + # update secret + if secret_id: - if case.account != account: - data = {'reason': 'retrieve Cases you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - else: - case.steps = steps - case.name = name - case.tags = tags - case.save() + # get secret + secret = Secret.objects.get(id=secret_id) + + # save new value + secret.value = encrypted_value + secret.save() - else: - case = Case.objects.create( - user = request.user, - name = name, - tags = tags, - steps = steps, - account = account + # create new secret + if not secret_id: + secret = Secret.objects.create( + account=account, + user=user, + name=name, + value=encrypted_value ) - + # serialize and return serializer_context = {'request': request,} - data = CaseSerializer(case, context=serializer_context).data - record_api_call(request, data, '201') - response = Response(data, status=status.HTTP_201_CREATED) - return response + serialized = SecretSerializer(secret, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) -def get_cases(request): - case_id = request.query_params.get('case_id') +def get_secrets(request: object=None) -> object: + """ + Get one or more `Secrets`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + secret_id = request.query_params.get('secret_id') + lean = request.query_params.get('lean') + + # get user and account user = request.user - account = Member.objects.get(user=user).account + member = Member.objects.get(user=user) + account = member.account - if case_id != None: - try: - case = Case.objects.get(id=case_id) - except: - data = {'reason': 'cannot find a Case with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - if case.account != account: - data = {'reason': 'retrieve an Case you do not own',} - return Response(data, status=status.HTTP_403_FORBIDDEN) - + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='secret', + action='get', id=secret_id, id_type='secret' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single secret + if secret_id: + + # get secret + secret = Secret.objects.get(id=secret_id) + + # serialize and return serializer_context = {'request': request,} - serialized = CaseSerializer(case, context=serializer_context) + serialized = SecretSerializer(secret, context=serializer_context) data = serialized.data record_api_call(request, data, '200') return Response(data, status=status.HTTP_200_OK) - cases = Case.objects.filter(account=account).order_by('-time_created') + # get secrets scoped to account + secrets = Secret.objects.filter(account=account).order_by('-time_created') + + # serialize and return paginator = LimitOffsetPagination() - result_page = paginator.paginate_queryset(cases, request) + result_page = paginator.paginate_queryset(secrets, request) serializer_context = {'request': request,} - serialized = CaseSerializer(result_page, many=True, context=serializer_context) + serialized = SecretSerializer(result_page, many=True, context=serializer_context) response = paginator.get_paginated_response(serialized.data) record_api_call(request, response.data, '200') return response @@ -1576,255 +6572,324 @@ def get_cases(request): -def search_cases(request): - user = request.user - account = Member.objects.get(user=user).account - query = request.query_params.get('query') - cases = Case.objects.filter(account=account, name__icontains=query).order_by('-time_created') - paginator = LimitOffsetPagination() - result_page = paginator.paginate_queryset(cases, request) - serializer_context = {'request': request,} - serialized = CaseSerializer(result_page, many=True, context=serializer_context) - response = paginator.get_paginated_response(serialized.data) - record_api_call(request, response.data, '200') - return response +def get_secret(request: object=None, id: str=None) -> object: + """ + Get single `Secret` from the passed "id" + Expects: { + 'request' : object, + 'id' : str + } + Returns -> HTTP Response object + """ -def delete_case(request, id): + # get user and account user = request.user - account = Member.objects.get(user=user).account - - try: - case = Case.objects.get(id=id) - except: - data = {'reason': 'cannot find a Case with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - if case.account != account: - data = {'reason': 'delete an Case you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + member = Member.objects.get(user=user) + account = member.account - case.delete() + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='secret', + action='get', id=id, id_type='secret' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) - data = {'message': 'Case has been deleted',} + # get secrets if checks passed + secrets = Secret.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = SecretSerializer(secrets, context=serializer_context) + data = serialized.data record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response - - + return Response(data, status=status.HTTP_200_OK) +def get_secrets_all(request: object=None) -> object: + """ + Get all `Secrets` associated with the + equesting user's `Account`. + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ -def create_testcase(request, delay=False): - case_id = request.data.get('case_id') - site_id = request.data.get('site_id') - updates = request.data.get('updates') - configs = request.data.get('configs') + # get user and account user = request.user - account = Member.objects.get(user=user).account + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='secret', + action='get' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get secrets scoped to account + secrets = Secret.objects.filter(account=account).order_by('-time_created') + + # build into list + data = [] + for secret in secrets: + data.append({ + 'name': secret.name, + 'value': secret.name, + 'task': 'any' + }) + + # return list + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) - account_is_active = check_account(request) - if not account_is_active: - data = {'reason': 'account not funded',} - record_api_call(request, data, '402') - return Response(data, status=status.HTTP_402_PAYMENT_REQUIRED) - if case_id and site_id: - try: - case = Case.objects.get(id=case_id, account=account) - except: - data = {'reason': 'cannot find a Case with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - try: - site = Site.objects.get(id=site_id, account=account) - except: - data = {'reason': 'cannot find a Site with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - else: - data = {'reason': 'you must provide both site_id and case_id'} - record_api_call(request, data, '409') - return Response(data, status=status.HTTP_409_CONFLICT) +def delete_secret(request: object=None, id: str=None, user: object=None) -> object: + """ + Deletes the `Secret` associated with the passed "id" - steps = case.steps - for step in steps: - if step['action']['type'] != None: - step['action']['time_created'] = None - step['action']['time_completed'] = None - step['action']['exception'] = None - step['action']['passed'] = None + Expcets: { + 'request' : object, + 'id' : str, + 'user' : object + } - if step['assertion']['type'] != None: - step['assertion']['time_created'] = None - step['assertion']['time_completed'] = None - step['assertion']['exception'] = None - step['assertion']['passed'] = None + Returns -> HTTP Response object + """ - if updates != None: - for update in updates: - steps[int(update['index'])]['action']['value'] = update['value'] + # get user and account info + if request: + user = request.user + member = Member.objects.get(user=user) + account = member.account - if configs is None: - configs = { - 'window_size': '1920,1080', - 'device': 'desktop', - 'interval': 5, - 'min_wait_time': 10, - 'max_wait_time': 30, - } - - testcase = Testcase.objects.create( - case = case, - case_name = case.name, - site = site, - user = request.user, - configs = configs, - steps = steps, - account = account + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='secret', + action='delete', id=id, id_type='secret' ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + if request: + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + return data + + # get secret if checks passed + secret = Secret.objects.get(id=id) + + # delete secret + secret.delete() + + # return response + data = {'message': 'Secret has been deleted',} + if request: + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + return data - if delay: - # pass the newly created Testcase to the backgroud task to run - create_testcase_bg.delay(testcase_id=testcase.id) - else: - # running testcase - asyncio.run( - Caser(testcase=testcase).run() - ) - testcase = Testcase.objects.get(id=testcase.id) - serializer_context = {'request': request,} - data = TestcaseSerializer(testcase, context=serializer_context).data - record_api_call(request, data, '201') - response = Response(data, status=status.HTTP_201_CREATED) - return response + + +### ------ Begin Process Services ------ ### -def get_testcases(request): - testcase_id = request.query_params.get('testcase_id') +def get_processes(request: object=None) -> object: + """ + Get one or more `Processes`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data site_id = request.query_params.get('site_id') - lean = request.query_params.get('lean') + process_id = request.query_params.get('process_id') + _type = request.query_params.get('type') + object_id = request.query_params.get('object_id') + + # get user and account user = request.user - account = Member.objects.get(user=user).account + member = Member.objects.get(user=user) + account = member.account - if testcase_id != None: - try: - testcase = Testcase.objects.get(id=testcase_id) - except: - data = {'reason': 'cannot find a Testcase with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - if testcase.account != account: - data = {'reason': 'retrieve an Testcase you do not own',} - return Response(data, status=status.HTTP_403_FORBIDDEN) + id = process_id if process_id else site_id + id_type = 'process' if process_id else 'site' + + # checking account and resource + check_data = check_permissions_and_usage( + member=member, resource='process', + action='get', id=id, id_type=id_type + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single process + if process_id: + + # get process + process = Process.objects.get(id=process_id) + # serialize and return serializer_context = {'request': request,} - serialized = TestcaseSerializer(testcase, context=serializer_context) - data = serialized.data + data = ProcessSerializer(process, context=serializer_context).data record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + response = Response(data, status=status.HTTP_200_OK) + return response - if site_id != None: - try: - site = Site.objects.get(id=site_id, account=account) - except: - data = {'reason': 'cannot find a Site with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - testcases = Testcase.objects.filter(site=site).order_by('-time_created') - - else: - testcases = Testcase.objects.filter(account=account).order_by('-time_created') + # get processes scoped to site + if site_id: + site = Site.objects.get(id=site_id) + processes = Process.objects.filter(site=site).order_by('-time_created') + # get processes scoped to accout and/or type + if site_id is None and process_id is None: + if _type is None and object_id is None: + processes = Process.objects.filter(account=account).order_by('-time_created') + if _type is not None: + processes = Process.objects.filter(account=account, type=_type).order_by('-time_created') + if object_id is not None: + processes = Process.objects.filter(account=account, object_id=object_id).order_by('-time_created') + + # filter out all non permissioned sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + processes = processes.filter(site__id__in=id_list).order_by('-time_created') + + # serialize and return paginator = LimitOffsetPagination() - result_page = paginator.paginate_queryset(testcases, request) + result_page = paginator.paginate_queryset(processes, request) serializer_context = {'request': request,} - serialized = TestcaseSerializer(result_page, many=True, context=serializer_context) - if lean is not None: - serialized = SmallTestcaseSerializer(result_page, many=True, context=serializer_context) + serialized = ProcessSerializer(result_page, many=True, context=serializer_context) response = paginator.get_paginated_response(serialized.data) record_api_call(request, response.data, '200') return response -def delete_testcase(request, id): - user = request.user - account = Member.objects.get(user=user).account - try: - testcase = Testcase.objects.get(id=id) - except: - data = {'reason': 'cannot find a Testcase with that id'} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - if testcase.account != account: - data = {'reason': 'delete an Testcase you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) +def get_process(request: object=None, id: str=None) -> object: + """ + Get single `Process` from the passed "id" - # remove s3 objects - delete_testcase_s3_bg.delay(testcase_id=id) + Expects: { + 'request' : object, + 'id' : str + } - testcase.delete() + Returns -> HTTP Response object + """ - data = {'message': 'Testcase has been deleted',} - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='process', + action='get', id=id, id_type='process' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + # get process if checks passed + process = Process.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = ProcessSerializer(process, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) +### ------ Begin Log Services ------ ### +def get_logs(request: object=None) -> object: + """ + Get one or more `CaseRun`. -def get_logs(request): + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + # get request data log_id = request.query_params.get('log_id') - request_status = request.query_params.get('status') + request_status = request.query_params.get('success') request_type = request.query_params.get('request_type') - if log_id != None: + # get user + user = request.user + member = Member.objects.get(user=user) + account = member.account + + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='log', + action='get', id=log_id, id_type='log' + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get single log + if log_id: + + # get log log = Log.objects.get(id=log_id) - if log.user != request.user: - data = {'reason': 'retrieve Logs you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) + # serialize and return serializer_context = {'request': request,} serialized = LogSerializer(log, context=serializer_context) data = serialized.data record_api_call(request, data, '200') return Response(data, status=status.HTTP_200_OK) + # filtering logs by passed params if request_status != None and request_type != None: - logs = Log.objects.filter(status=request_status, request_type=request_type, user=request.user).order_by('-time_created') + logs = Log.objects.filter(status=request_status, request_type=request_type, user=user).order_by('-time_created') elif request_status == None and request_type != None: - logs = Log.objects.filter(request_type=request_type, user=request.user).order_by('-time_created') + logs = Log.objects.filter(request_type=request_type, user=user).order_by('-time_created') elif request_status != None and request_type == None: - logs = Log.objects.filter(status=request_status, user=request.user).order_by('-time_created') + logs = Log.objects.filter(status=request_status, user=user).order_by('-time_created') else: - logs = Log.objects.filter(user=request.user).order_by('-time_created') + logs = Log.objects.filter(user=user).order_by('-time_created') + # serialize and return paginator = LimitOffsetPagination() result_page = paginator.paginate_queryset(logs, request) serializer_context = {'request': request,} @@ -1835,197 +6900,550 @@ def get_logs(request): +def get_log(request: object=None, id: str=None) -> object: + """ + Get single `Log` from the passed "id" + Expects: { + 'request' : object, + 'id' : str + } + Returns -> HTTP Response object + """ + # get user and account + user = request.user + member = Member.objects.get(user=user) + account = member.account -def migrate_site(request, delay=False): - login_url = request.data.get('login_url', None) - admin_url = request.data.get('admin_url', None) - plugin_name = request.data.get('plugin_name', 'Cloudways WordPress Migrator') - username = request.data.get('username', None) - password = request.data.get('password', None) - site_id = request.data.get('site_id', None) - email_address = request.data.get('email_address', None) - destination_url = request.data.get('destination_url', None) - sftp_address = request.data.get('sftp_address', None) - dbname = request.data.get('dbname', None) - sftp_username = request.data.get('sftp_username', None) - sftp_password = request.data.get('sftp_password', None) - wait_time = request.data.get('wait_time', 30) - driver = request.data.get('driver', 'puppeteer') - - site = Site.objects.get(id=site_id) - process = Process.objects.create( - site=site, - type='migration' + # check account and resource + check_data = check_permissions_and_usage( + member=member, resource='log', + action='get', id=id, id_type='log' ) - process_id = process.id - - if delay: - migrate_site_bg.delay( - login_url, - admin_url, - username, - password, - email_address, - destination_url, - sftp_address, - dbname, - sftp_username, - sftp_password, - plugin_name, - wait_time, - process_id, - driver - ) - - serializer_context = {'request': request,} - data = ProcessSerializer(process, context=serializer_context).data - record_api_call(request, data, '201') - response = Response(data, status=status.HTTP_201_CREATED) - return response - + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + # get log if checks passed + log = Log.objects.get(id=id) + + # serialize and return + serializer_context = {'request': request,} + serialized = LogSerializer(log, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) - if driver == 'selenium': - # init wordpress - wp = W( - login_url=login_url, - admin_url=admin_url, - username=username, - password=password, - email_address=email_address, - destination_url=destination_url, - sftp_address=sftp_address, - dbname=dbname, - sftp_username=sftp_username, - sftp_password=sftp_password, - wait_time=wait_time, - process_id=process.id - ) - # login - wp_status = wp.login() - # adjust lang - wp_status = wp.begin_lang_check() +### ------ Begin Search Services ------ ### - # install plugin - wp_status = wp.install_plugin(plugin_name=plugin_name) - # launch migration - wp_status = wp.launch_migration() - # run migration - wp_status = wp.run_migration() - # re adjust lang - # wp_status = wp.end_lang_check() +def search_resources(request: object=None) -> object: + """ + This method will search for any `Page` or `Site` + that is associated with the user's `Account` and + matches the query string. - if wp_status: - data = { - 'status': 'success', - 'message': 'site migration succeeded' - } - else: - data = { - 'status': 'failed', - 'message': 'site migration failed' + Expects: + 'query': the query string + + Returns: + data -> [ + { + 'str' : , + 'type': , + 'path': , + 'id' : , } + ... + ] + """ - response = Response(data, status=status.HTTP_200_OK) - record_api_call(request, data, '200') - return response + # get data + query = request.query_params.get('query') + user = request.user + member = Member.objects.get(user=user) + account = member.account + actions = member.permissions.get('actions', []) + resources = member.permissions.get('resources', []) + allowed_ids = [item['id'] for item in member.permissions.get('sites')] + data = [] + cases = [] + pages = [] + sites = [] + issues = [] + flows = [] + + # check action permissons + if 'get' not in actions: + data = {'reason': 'not allowed',} + record_api_call(request, data, '403') + return Response(data, status=status.HTTP_403_FORBIDDEN) - else: + # check for object specification i.e 'site:', 'case:', 'issue:' + resource_type = query.replace('https://', '').replace('http://', '').split(':')[0] + query = query.replace('https://', '').replace('http://', '').split(':')[-1] - # init wordpress for puppeteer - wp_status = asyncio.run( - W_P( - login_url=login_url, - admin_url=admin_url, - username=username, - password=password, - wait_time=wait_time, - ).run_full(plugin_name=plugin_name) + # search for sites + if (resource_type == 'site' or resource_type == query) and 'site' in resources: + sites = Site.objects.filter(account=account).filter( + site_url__icontains=query + ) + # filter out all non permisioned + if len(allowed_ids) > 0: + sites = sites.filter(id__in=allowed_ids) + + # search for pages + if (resource_type == 'page' or resource_type == query) and 'page' in resources: + pages = Page.objects.filter(account=account).filter( + page_url__icontains=query + ) + # filter out all non permisioned + if len(allowed_ids) > 0: + pages = pages.filter(site__id__in=allowed_ids) + + # search for cases + if (resource_type == 'case' or resource_type == query) and 'case' in resources: + cases = Case.objects.filter(account=account).filter( + title__icontains=query + ) + # filter out all non permisioned + if len(allowed_ids) > 0: + cases = cases.filter(site__id__in=allowed_ids) + + # search for issues + if (resource_type == 'issue' or resource_type == query) and 'issue' in resources: + issues = Issue.objects.filter(account=account).filter( + title__icontains=query + ) + # filter out all non permisioned + if len(allowed_ids) > 0: + new_ids = allowed_ids + for id in allowed_ids: + for page in Page.objects.filter(site__id=id): + new_ids.append(str(page.id)) + issues = issues.filter(affected__id__in=new_ids) + + # search for flows + if (resource_type == 'flow' or resource_type == query) and 'flow' in resources: + flows = Flow.objects.filter(account=account).filter( + title__icontains=query ) - if wp_status: - data = { - 'status': 'success', - 'message': 'site migration succeeded' - } - else: - data = { - 'status': 'failed', - 'message': 'site migration failed' - } + # adding first several sites if present + i = 0 + sites_allowed = 10 if resource_type == 'site' else 3 + while i <= sites_allowed and i <= (len(sites)-1): + data.append({ + 'str': str(sites[i].site_url), + 'path': f'/site/{sites[i].id}', + 'id' : str(sites[i].id), + 'type': 'site', + }) + i+=1 + + # adding first several pages if present + i = 0 + max_pages = 10 if resource_type == 'page' else 3 + while i <= max_pages and i <= (len(pages)-1): + data.append({ + 'str': str(pages[i].page_url), + 'path': f'/page/{pages[i].id}', + 'id' : str(pages[i].id), + 'type': 'page', + }) + i+=1 + + # adding first several cases if present + i = 0 + max_cases = 10 if resource_type == 'case' else 3 + while i <= max_cases and i <= (len(cases)-1): + data.append({ + 'str': str(cases[i].title), + 'path': f'/case/{cases[i].id}', + 'id' : str(cases[i].id), + 'type': 'case', + }) + i+=1 + + # adding first several issues if present + i = 0 + max_issues = 10 if resource_type == 'issue' else 3 + while i <= max_issues and i <= (len(issues)-1): + data.append({ + 'str': str(issues[i].title), + 'path': f'/issue/{issues[i].id}', + 'id' : str(issues[i].id), + 'type': 'issue', + }) + i+=1 + + # adding first several flows if present + i = 0 + max_flows = 10 if resource_type == 'flows' else 2 + while i <= max_flows and i <= (len(flows)-1): + data.append({ + 'str': str(flows[i].title), + 'path': f'/flow/{flows[i].id}', + 'id' : str(flows[i].id), + 'type': 'flow', + }) + i+=1 + + # return response + response = Response(data, status=status.HTTP_200_OK) + return response - response = Response(data, status=status.HTTP_200_OK) - record_api_call(request, data, '200') - return response +def get_devices(request: object=None) -> object: + """ + Retrieves a list of all Cursion "devices" + + Expects: None + + Returns -> HTTP Response object + """ + + # format data + data = { + 'devices': devices + } + + # return response + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +### ------ Begin Metrics Services ------ ### +def get_home_metrics(request: object=None) -> object: + """ + Builds metrics for account "Home" view + on Cursion.client + Expects: { + 'request' : object + } + Returns -> HTTP Response object + """ -def create_site_screenshot(request): + # get user, account, sites, & issues user = request.user - site_id = request.data.get('site_id', None) - url = request.data.get('url', None) - configs = request.data.get('configs', None) - site = None + member = Member.objects.get(user=user) + account = member.account + sites = Site.objects.filter(account=account).count() + issues = Issue.objects.filter(account=account, status='open') + schedules = Schedule.objects.filter(account=account).count() + + # filter issues by allowed sites + if len(member.permissions.get('sites',[])) != 0: + id_list = [item['id'] for item in member.permissions.get('sites')] + new_ids = id_list + for id in id_list: + for page in Page.objects.filter(site__id=id): + new_ids.append(str(page.id)) + issues = issues.filter(affected__id__in=new_ids) + + # setting resource defaults + tests = account.usage['tests'] + scans = account.usage['scans'] + caseruns = account.usage['caseruns'] + flowruns = account.usage.get('flowruns', 0) + issues = issues.count() + + # calculate usages + sites_usage = round((sites/account.usage['sites_allowed'])*100, 2) if sites > 0 else 0 + schedules_usage = round((schedules/account.usage['schedules_allowed'])*100, 2) if schedules > 0 else 0 + scans_usage = round((scans/account.usage['scans_allowed'])*100, 2) if scans > 0 else 0 + tests_usage = round((tests/account.usage['tests_allowed'])*100, 2) if tests > 0 else 0 + caseruns_usage = round((caseruns/account.usage['caseruns_allowed'])*100, 2) if caseruns > 0 else 0 + flowruns_usage = round((flowruns/account.usage['flowruns_allowed'])*100, 2) if flowruns > 0 else 0 + + # format data + data = { + "sites": sites, + "sites_usage": sites_usage, + "tests": tests, + "tests_usage": tests_usage, + "scans": scans, + "scans_usage": scans_usage, + "schedules": schedules, + "schedules_usage": schedules_usage, + "caseruns": caseruns, + "caseruns_usage": caseruns_usage, + "flowruns": flowruns, + "flowruns_usage": flowruns_usage, + "open_issues": issues, + } + + # return response + response = Response(data, status=status.HTTP_200_OK) + return response - if site_id is not None: - site = Site.objects.get(id=site_id) - if configs is not None: - if configs['driver'] == 'puppeteer': - data = asyncio.run(I().screenshot_p(site=site, url=url, configs=configs)) - elif configs['driver'] == 'selenium': - data = I().screenshot(site=site, url=url, configs=configs) + + +def get_site_metrics(request: object=None) -> object: + """ + Builds metrics for account "Site" view + on Cursion.client + + Expects: { + 'request' : object + } + + Returns -> HTTP Response object + """ + + # get user, account, site, & pages + user = request.user + member = Member.objects.get(user=user) + account = member.account + site_id = request.query_params.get('site_id') + site = Site.objects.get(id=site_id) + sites_allowed = account.usage['sites_allowed'] + pages = Page.objects.filter(site=site) + + # get last reset day + f = '%Y-%m-%d %H:%M:%S.%f' + last_usage_date_str = account.meta.get('last_usage_reset') + last_usage_date = None + if last_usage_date_str: + last_usage_date_str = last_usage_date_str.replace('T', ' ').replace('Z', '') + last_usage_date = datetime.strptime(last_usage_date_str, f) else: - data = I().screenshot(site=site, url=url, configs=configs) - record_api_call(request, data, '201') - response = Response(data, status=status.HTTP_201_CREATED) + last_usage_date = datetime.now() - timedelta(30) + + # get scans + scans = Scan.objects.filter( + site=site, + time_created__gte=last_usage_date + ).count() + + # get tests + tests = Test.objects.filter( + site=site, + time_created__gte=last_usage_date + ).count() + + # get caseruns + caseruns = CaseRun.objects.filter( + site=site, + time_created__gte=last_usage_date + ).count() + + # get flowruns + flowruns = FlowRun.objects.filter( + site=site, + time_created__gte=last_usage_date + ).count() + + # get site scoped schedules + schedules = Schedule.objects.filter( + resources__icontains=str(site.id), scope='site', + account=account + ).count() + + # calculating page scoped schedules + for page in pages: + schedules += Schedule.objects.filter( + resources__icontains=str(page.id), scope='page', + account=account + ).count() + + # calculate usage + pages = pages.count() + pages_usage = round((pages/account.usage['pages_allowed'])*100, 2) if pages > 0 else 0 + schedules_usage = round((schedules/account.usage['schedules_allowed'])*100, 2) if schedules > 0 else 0 + scans_usage = round((scans/account.usage['scans_allowed'])*100, 2) if scans > 0 else 0 + tests_usage = round((tests/account.usage['tests_allowed'])*100, 2) if tests > 0 else 0 + caseruns_usage = round((caseruns/account.usage['caseruns_allowed'])*100, 2) if caseruns > 0 else 0 + flowruns_usage = round((flowruns/account.usage['flowruns_allowed'])*100, 2) if flowruns > 0 else 0 + + # format data + data = { + "pages": pages, + "pages_usage": pages_usage, + "tests": tests, + "tests_usage": tests_usage, + "scans": scans, + "scans_usage": scans_usage, + "schedules": schedules, + "schedules_usage": schedules_usage, + "caseruns": caseruns, + "caseruns_usage": caseruns_usage, + "flowruns": flowruns, + "flowruns_usage": flowruns_usage, + } + + # return response + response = Response(data, status=status.HTTP_200_OK) return response +def get_celery_metrics(request: object=None) -> object: + """ + Builds metrics for current Celery task load. + Used to provision and terminate new pods in + k8s cluster on PROD + Expects: { + 'request' : object + } + Returns -> HTTP Response object + """ -def get_home_stats(request): - user = request.user - account = Member.objects.get(user=user).account - sites = Site.objects.filter(account=account) - site_count = sites.count() - test_count = 0 - scan_count = 0 - schedule_count = 0 - for site in sites: - tests = Test.objects.filter(site=site) - scans = Scan.objects.filter(site=site) - schedules = Schedule.objects.filter(site=site) - test_count = test_count + tests.count() - scan_count = scan_count + scans.count() - schedule_count = schedule_count + schedules.count() + + # get redis queue len + redis_client = Redis.from_url( + settings.CELERY_BROKER_URL, + socket_connect_timeout=3 + ) + redis_queue_len = redis_client.llen( + app.default_app.conf.task_default_queue + ) + # Inspect all nodes. + i = celery.app.control.inspect() + + # Tasks received, but are still waiting to be executed. + reserved = i.reserved() + + # Active tasks + active = i.active() + + # init task & replica counters & ratio + num_tasks = 0 + num_replicas = 0 + ratio = 0 + working_len = 0 + + # loop through all reserved & active tasks and + # add length of array (tasks) to total + for replica in reserved: + num_tasks += len(reserved[replica]) + num_replicas += 1 + for replica in active: + num_tasks += len(active[replica]) + + # build metrics + if num_replicas > 0: + ratio = num_tasks / num_replicas + + # get working length + working_len = redis_queue_len + num_tasks + + # format data data = { - "sites": site_count, - "tests": test_count, - "scans": scan_count, - "schedules": schedule_count, + "num_tasks": num_tasks, + "num_replicas": num_replicas, + "ratio": ratio, + "redis_queue": redis_queue_len, + "working_len": working_len } + + # return response response = Response(data, status=status.HTTP_200_OK) return response + + +### ------ Begin Beta Services ------ ### + + + + +def migrate_site(request: object=None) -> object: + """ + Initiate a `Site` migration task in background + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + login_url = request.data.get('login_url', None) + admin_url = request.data.get('admin_url', None) + plugin_name = request.data.get('plugin_name', 'Cloudways WordPress Migrator') + username = request.data.get('username', None) + password = request.data.get('password', None) + site_id = request.data.get('site_id', None) + email_address = request.data.get('email_address', None) + destination_url = request.data.get('destination_url', None) + sftp_address = request.data.get('sftp_address', None) + dbname = request.data.get('dbname', None) + sftp_username = request.data.get('sftp_username', None) + sftp_password = request.data.get('sftp_password', None) + wait_time = request.data.get('wait_time', 30) + driver = request.data.get('driver', 'selenium') + + # checking account and resource + check_data = check_permissions_and_usage( + request=request, resource='site', + site_id=site_id + ) + if not check_data['allowed']: + data = {'reason': check_data['error'],} + record_api_call(request, data, check_data['code']) + return Response(data, status=check_data['status']) + + # get site if checks passed + site = Site.objects.get(id=site_id) + + # create new Process + process = Process.objects.create( + site=site, + type='migration' + ) + + # start migrtation task in background + migrate_site_bg.delay( + login_url, + admin_url, + username, + password, + email_address, + destination_url, + sftp_address, + dbname, + sftp_username, + sftp_password, + plugin_name, + wait_time, + process.id, + driver + ) + + # serialize and return + serializer_context = {'request': request,} + data = ProcessSerializer(process, context=serializer_context).data + record_api_call(request, data, '201') + response = Response(data, status=status.HTTP_201_CREATED) + return response + + + + + diff --git a/app/api/v1/ops/tasks.py b/app/api/v1/ops/tasks.py deleted file mode 100644 index 8dc20b9e..00000000 --- a/app/api/v1/ops/tasks.py +++ /dev/null @@ -1,360 +0,0 @@ -from ...models import * -from ...utils.scanner import Scanner as S -from ...utils.tester import Tester as T -from ...utils.reporter import Reporter as R -from ...utils.wordpress import Wordpress as W -from ...utils.wordpress_p import Wordpress as W_P -from ...utils.automations import automation -from ...utils.caser import Caser -import boto3, asyncio -from scanerr import settings -from ...utils.scanner import ( - _html_and_logs, _vrt, _lighthouse, - _yellowlab -) - - - -def create_site_task(site_id, scan_id, configs): - site = Site.objects.get(id=site_id) - scan = Scan.objects.get(id=scan_id) - S(site=site, scan=scan, configs=configs).first_scan() - return site - - -def create_scan_task( - scan_id=None, - site_id=None, - type=['full'], - automation_id=None, - configs=None, - tags=None, - ): - if scan_id is not None: - created_scan = Scan.objects.get(id=scan_id) - elif site_id is not None: - site = Site.objects.get(id=site_id) - created_scan = Scan.objects.create( - site=site, - type=type, - configs=configs, - tags=tags, - ) - scan = S(scan=created_scan, configs=configs).first_scan() - if automation_id: - automation(automation_id, scan.id) - return scan - - - - - -def run_html_and_logs_task(scan_id=None): - scan = _html_and_logs(scan_id) - return scan - -def run_vrt_task(scan_id=None): - scan = _vrt(scan_id) - return scan - -def run_lighthouse_task(scan_id=None): - scan = _lighthouse(scan_id) - return scan - -def run_yellowlab_task(scan_id=None): - scan = _yellowlab(scan_id) - return scan - - - - - -def create_test_task( - test_id=None, - site_id=None, - automation_id=None, - configs=None, - type=['full'], - index=None, - pre_scan=None, - post_scan=None, - tags=None, - ): - - if test_id is not None: - created_test = Test.objects.get(id=test_id) - site = created_test.site - elif site_id is not None: - site = Site.objects.get(id=site_id) - created_test = Test.objects.create( - site=site, - type=type, - tags=tags, - ) - - if pre_scan is not None: - pre_scan = Scan.objects.get(id=pre_scan) - if post_scan is not None: - post_scan = Scan.objects.get(id=post_scan) - - if post_scan is None and pre_scan is not None: - post_scan = S(site=site, scan=pre_scan, configs=configs, type=type).second_scan() - - if pre_scan is None and post_scan is None: - new_scan = S(site=site, configs=configs, type=type) - post_scan = new_scan.second_scan() - pre_scan = post_scan.paired_scan - - # updating parired scans - pre_scan.paired_scan = post_scan - post_scan.paried_scan = pre_scan - pre_scan.save() - post_scan.save() - - # updating test object - created_test.type = type - created_test.pre_scan = pre_scan - created_test.post_scan = post_scan - created_test.save() - - - test = T(test=created_test).run_test(index=index) - if automation_id: - automation(automation_id, test.id) - return test - - - - -def create_report_task(site_id, automation_id=None): - site = Site.objects.get(id=site_id) - if Report.objects.filter(site=site).exists(): - report = Report.objects.filter(site=site).order_by('-time_created')[0] - else: - info = { - "text_color": '#24262d', - "background_color": '#e1effd', - "highlight_color": '#ffffff', - } - report = Report.objects.create( - user=site.user, - site=site, - info=info, - type=['lighthouse', 'yellowlab'] - ) - - - report = R(report=report).make_test_report() - if automation_id: - automation(automation_id, report.id) - return report - - - - - - -def delete_site_s3(site_id): - # setup boto3 configurations - s3 = boto3.resource('s3', - aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), - aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), - region_name=str(settings.AWS_S3_REGION_NAME), - endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) - ) - - # deleting s3 objects - try: - bucket = s3.Bucket(settings.AWS_STORAGE_BUCKET_NAME) - bucket.objects.filter(Prefix=str(f'static/sites/{site_id}/')).delete() - except: - pass - - return - - - - -def delete_testcase_s3(testcase_id): - # setup boto3 configurations - s3 = boto3.resource('s3', - aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), - aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), - region_name=str(settings.AWS_S3_REGION_NAME), - endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) - ) - - # deleting s3 objects - try: - bucket = s3.Bucket(settings.AWS_STORAGE_BUCKET_NAME) - bucket.objects.filter(Prefix=str(f'static/testcase/{testcase_id}/')).delete() - except: - pass - - return - - - - - -def delete_report_s3(report_id): - # setup boto3 configurations - s3 = boto3.resource('s3', - aws_access_key_id=str(settings.AWS_ACCESS_KEY_ID), - aws_secret_access_key=str(settings.AWS_SECRET_ACCESS_KEY), - region_name=str(settings.AWS_S3_REGION_NAME), - endpoint_url=str(settings.AWS_S3_ENDPOINT_URL) - ) - - # get site - site = Report.objects.get(id=report_id).site - - # deleting s3 objects - bucket = s3.Bucket(settings.AWS_STORAGE_BUCKET_NAME) - bucket.objects.filter(Prefix=str(f'static/sites/{site.id}/{report_id}.pdf')).delete() - - return - - - - -def create_testcase_task( - testcase_id=None, - site_id=None, - case_id=None, - updates=None, - configs=None, - automation_id=None - ): - - if testcase_id != None: - testcase = Testcase.objects.get(id=testcase_id) - - else: - case = Case.objects.get(id=case_id) - site = Site.objects.get(id=site_id) - steps = case.steps - for step in steps: - if step['action']['type'] != None: - step['action']['time_created'] = None - step['action']['time_completed'] = None - step['action']['exception'] = None - step['action']['passed'] = None - - if step['assertion']['type'] != None: - step['assertion']['time_created'] = None - step['assertion']['time_completed'] = None - step['assertion']['exception'] = None - step['assertion']['passed'] = None - - if updates != None: - for update in updates: - steps[int(update['index'])]['action']['value'] = update['value'] - - if configs is None: - configs = { - 'window_size': '1920,1080', - 'device': 'desktop', - 'interval': 5, - 'min_wait_time': 10, - 'max_wait_time': 30, - } - - testcase = Testcase.objects.create( - case = case, - case_name = case.name, - site = site, - user = site.user, - configs = configs, - steps = steps - ) - - - # running testcase - testresult = asyncio.run( - Caser(testcase=testcase).run() - ) - - if automation_id: - automation(automation_id, testcase.id) - - return - - - - -def migrate_site_task( - login_url, - admin_url, - username, - password, - email_address, - destination_url, - sftp_address, - dbname, - sftp_username, - sftp_password, - plugin_name, - wait_time, - process_id, - driver, - - ): - - if driver == 'selenium': - # init wordpress - wp = W( - login_url=login_url, - admin_url=admin_url, - username=username, - password=password, - email_address=email_address, - destination_url=destination_url, - sftp_address=sftp_address, - dbname=dbname, - sftp_username=sftp_username, - sftp_password=sftp_password, - wait_time=wait_time, - process_id=process_id, - - ) - - # login - wp_status = wp.login() - # adjust lang - wp_status = wp.begin_lang_check() - # install plugin - wp_status = wp.install_plugin(plugin_name=plugin_name) - # launch migration - wp_status = wp.launch_migration() - # run migration - wp_status = wp.run_migration() - # re adjust lang - # wp_status = wp.end_lang_check() - - else: - # init wordpress for puppeteer - wp_status = asyncio.run( - W_P( - login_url=login_url, - admin_url=admin_url, - username=username, - password=password, - email_address=email_address, - destination_url=destination_url, - sftp_address=sftp_address, - dbname=dbname, - sftp_username=sftp_username, - sftp_password=sftp_password, - wait_time=wait_time, - process_id=process_id, - ).run_full(plugin_name=plugin_name) - ) - - return - - - - - - \ No newline at end of file diff --git a/app/api/v1/ops/urls.py b/app/api/v1/ops/urls.py index f0d0f7ab..f93e8a0c 100644 --- a/app/api/v1/ops/urls.py +++ b/app/api/v1/ops/urls.py @@ -5,36 +5,72 @@ urlpatterns = [ path('site', views.Sites.as_view(), name='site'), path('site/', views.SiteDetail.as_view(), name='site-detail'), - path('site/delay', views.SiteDelay.as_view(), name='site-delay'), + path('site//crawl', views.SiteCrawl.as_view(), name='site-crawl'), path('sites/delete', views.SitesDelete.as_view(), name='sites-delete'), + path('sites/zapier', views.SitesZapier.as_view(), name='sites-zapier'), + path('page', views.Pages.as_view(), name='page'), + path('page/', views.PageDetail.as_view(), name='page-detail'), + path('pages/delete', views.PagesDelete.as_view(), name='pages-delete'), + path('pages/zapier', views.PagesZapier.as_view(), name='pages-zapier'), path('scan', views.Scans.as_view(), name='scan'), path('scan/', views.ScanDetail.as_view(), name='scan-detail'), path('scan//lean', views.ScanLean.as_view(), name='scan-lean'), - path('scan/delay', views.ScanDelay.as_view(), name='scan-delay'), path('scans/delete', views.ScansDelete.as_view(), name='scans-delete'), + path('scans/create', views.ScansCreate.as_view(), name='scans-create'), + path('scans/zapier', views.ScansZapier.as_view(), name='scans-zapier'), path('test', views.Tests.as_view(), name='test'), path('test/', views.TestDetail.as_view(), name='test-detail'), path('test//lean', views.TestLean.as_view(), name='test-lean'), - path('test/delay', views.TestDelay.as_view(), name='test-delay'), path('tests/delete', views.TestsDelete.as_view(), name='tests-delete'), - path('log', views.Logs.as_view(), name='log'), - path('log/', views.LogDetail.as_view(), name='log-detail'), - path('schedule', views.Schedules.as_view(), name='schedule'), - path('schedule/', views.ScheduleDetail.as_view(), name='schedule-detail'), - path('automation', views.Automations.as_view(), name='automation'), - path('automation/', views.AutomationDetail.as_view(), name='automation-detail'), + path('tests/create', views.TestsCreate.as_view(), name='tests-create'), + path('tests/zapier', views.TestsZapier.as_view(), name='tests-zapier'), + path('case', views.Cases.as_view(), name='case'), + path('case/', views.CaseDetail.as_view(), name='case-detail'), + path('case/search', views.CasesSearch.as_view(), name='case-search'), + path('case/auto', views.AutoCases.as_view(), name='case-auto'), + path('case/copy', views.CopyCases.as_view(), name='case-copy'), + path('cases/delete', views.CasesDelete.as_view(), name='cases-delete'), + path('cases/zapier', views.CasesZapier.as_view(), name='cases-zapier'), + path('caserun', views.CaseRuns.as_view(), name='caserun'), + path('caserun/', views.CaseRunDetail.as_view(), name='caserun-detail'), + path('caseruns/zapier', views.CaseRunsZapier.as_view(), name='caseruns-zapier'), + path('flow', views.Flows.as_view(), name='case'), + path('flow/', views.FlowDetail.as_view(), name='flow-detail'), + path('flow/search', views.FlowsSearch.as_view(), name='flows-search'), + path('flow/copy', views.CopyFlows.as_view(), name='flows-copy'), + path('flows/delete', views.FlowsDelete.as_view(), name='flows-delete'), + path('flows/zapier', views.FlowsZapier.as_view(), name='flows-zapier'), + path('flowrun', views.FlowRuns.as_view(), name='flowruns'), + path('flowrun/', views.FlowRunDetail.as_view(), name='flowruns-detail'), + path('flowruns/zapier', views.FlowRunsZapier.as_view(), name='flowruns-zapier'), + path('issue', views.Issues.as_view(), name='issue'), + path('issue/search', views.IssuesSearch.as_view(), name='issue-search'), + path('issue/', views.IssueDetail.as_view(), name='issue-detail'), + path('issues/update', views.IssuesUpdate.as_view(), name='issues-update'), + path('issues/delete', views.IssuesDelete.as_view(), name='issues-delete'), + path('issues/zapier', views.IssuesZapier.as_view(), name='issues-zapier'), path('report', views.Reports.as_view(), name='report'), path('report/', views.ReportDetail.as_view(), name='report-detail'), - path('home-stats', views.HomeStats.as_view(), name='home-stats'), + path('schedule', views.Schedules.as_view(), name='schedule'), + path('schedule/', views.ScheduleDetail.as_view(), name='schedule-detail'), + path('schedule/run', views.ScheduleRun.as_view(), name='schedule-run'), + path('schedules/update', views.SchedulesUpdate.as_view(), name='schedule-update'), + path('schedules/delete', views.SchedulesDelete.as_view(), name='schedule-delete'), + path('alert', views.Alerts.as_view(), name='alert'), + path('alert/', views.AlertDetail.as_view(), name='alert-detail'), path('process', views.Processes.as_view(), name='process'), path('process/', views.ProcessDetail.as_view(), name='process-detail'), - path('case', views.Cases.as_view(), name='case'), - path('case/', views.CaseDetail.as_view(), name='case-detail'), - path('case/search', views.CasesSearch.as_view(), name='case-search'), - path('testcase', views.Testcases.as_view(), name='testcase'), - path('testcase/delay', views.TestcaseDelay.as_view(), name='testcase-delay'), - path('testcase/', views.TestcaseDetail.as_view(), name='testcase-detail'), + path('secret', views.Secrets.as_view(), name='secret'), + path('secret/', views.SecretDetail.as_view(), name='secret-detail'), + path('secrets', views.SecretsAll.as_view(), name='secrets-all'), + path('log', views.Logs.as_view(), name='log'), + path('log/', views.LogDetail.as_view(), name='log-detail'), + path('search', views.Search.as_view(), name='search'), + path('device', views.Device.as_view(), name='device'), + path('metrics/home', views.HomeMetrics.as_view(), name='home-metrics'), + path('metrics/site', views.SiteMetrics.as_view(), name='site-metrics'), + path('metrics/celery', views.CeleryMetrics.as_view(), name='celery-metrics'), path('beta/wordpress/migrate', views.WordPressMigrateSite.as_view(), name='migrate-site'), - path('beta/wordpress/migrate/delay', views.WordPressMigrateSiteDelay.as_view(), name='migrate-site-delay'), - path('beta/site/screenshot', views.SiteScreenshot.as_view(), name='site-screenshot'), + path('beta/site/screenshot', views.SiteScreenshot.as_view(), name='site-screenshot'), + path('beta/report/export', views.ExportReport.as_view(), name='export-report'), ] \ No newline at end of file diff --git a/app/api/v1/ops/views.py b/app/api/v1/ops/views.py index 2bc92ea0..5225cbe5 100644 --- a/app/api/v1/ops/views.py +++ b/app/api/v1/ops/views.py @@ -7,9 +7,8 @@ from django.urls import path, include from rest_framework import routers, serializers, viewsets from rest_framework.viewsets import ViewSet -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated from django.views.decorators.csrf import ensure_csrf_cookie from rest_framework.pagination import LimitOffsetPagination from django.urls import resolve @@ -18,8 +17,16 @@ + + + +### ------ Begin Site Views ------ ### + + + + class Sites(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post', 'get'] pagination_class = LimitOffsetPagination @@ -30,26 +37,17 @@ def post(self, request): def get(self, request): response = get_sites(request) return response - + + class SiteDetail(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'delete'] def get(self, request, id): - site = get_object_or_404(Site, pk=id) - user = request.user - account = Member.objects.get(user=user).account - if site.account != account: - data = {'reason': 'you cannot retrieve a Site you do not own',} - record_api_call(request, data, '401') - return Response(data, status=status.HTTP_403_FORBIDDEN) - serializer_context = {'request': request,} - serialized = SiteSerializer(site, context=serializer_context) - data = serialized.data - record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + response = get_site(request, id) + return response def delete(self, request, id): response = delete_site(request, id) @@ -57,18 +55,20 @@ def delete(self, request, id): -class SiteDelay(APIView): - permission_classes = (AllowAny,) + +class SiteCrawl(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['post',] - def post(self, request): - response = create_site(request, delay=True) + def post(self, request, id): + response = crawl_site(request, id) return response + class SitesDelete(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): @@ -78,8 +78,82 @@ def post(self, request): +class SitesZapier(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_sites_zapier(request) + return response + + + + +### ------ Begin Page Views ------ ### + + + + +class Pages(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post', 'get'] + pagination_class = LimitOffsetPagination + + def post(self, request): + response = create_page(request) + return response + + def get(self, request): + response = get_pages(request) + return response + + + + +class PageDetail(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get', 'delete'] + + def get(self, request, id): + response = get_page(request, id) + return response + + def delete(self, request, id): + response = delete_page(request, id) + return response + + + + +class PagesDelete(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post',] + + def post(self, request): + response = delete_many_pages(request) + return response + + + + +class PagesZapier(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_pages_zapier(request) + return response + + + + +### ------ Begin Scan Views ------ ### + + + + class Scans(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post', 'get',] pagination_class = LimitOffsetPagination @@ -92,25 +166,15 @@ def get(self, request): return response + + class ScanDetail(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'delete',] def get(self, request, id): - scan = get_object_or_404(Scan, pk=id) - user = request.user - account = Member.objects.get(user=user).account - - if scan.site.account != account: - data = {'reason': 'you cannot retrieve Scans of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - - serializer_context = {'request': request,} - serialized = ScanSerializer(scan, context=serializer_context) - data = serialized.data - record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + response = get_scan(request, id) + return response def delete(self, request, id): @@ -118,8 +182,10 @@ def delete(self, request, id): return response + + class ScanLean(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get', ] def get(self, request, id): @@ -127,17 +193,21 @@ def get(self, request, id): return response -class ScanDelay(APIView): - permission_classes = (AllowAny,) + + +class ScansCreate(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): - response = create_scan(request, delay=True) + response = create_many_scans(request) return response + + class ScansDelete(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): @@ -147,9 +217,24 @@ def post(self, request): +class ScansZapier(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_scans_zapier(request) + return response + + + + +### ------ Begin Test Views ------ ### + + + class Tests(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post', 'get',] pagination_class = LimitOffsetPagination @@ -162,33 +247,25 @@ def get(self, request): return response + + class TestDetail(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'delete',] def get(self, request, id): - test = get_object_or_404(Test, pk=id) - user = request.user - account = Member.objects.get(user=user).account - - if test.site.account != account: - data = {'reason': 'you cannot retrieve Tests of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - - serializer_context = {'request': request,} - serialized = TestSerializer(test, context=serializer_context) - data = serialized.data - record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + response = get_test(request, id) + return response def delete(self, request, id): response = delete_test(request, id) return response + + class TestLean(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get',] def get(self, request, id): @@ -196,17 +273,21 @@ def get(self, request, id): return response -class TestDelay(APIView): - permission_classes = (AllowAny,) + + +class TestsCreate(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): - response = create_test(request, delay=True) + response = create_many_tests(request) return response + + class TestsDelete(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): @@ -216,10 +297,24 @@ def post(self, request): +class TestsZapier(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_tests_zapier(request) + return response + + + + +### ------ Begin Schedule Views ------ ### + + class Schedules(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post', 'get'] def post(self, request): @@ -231,25 +326,15 @@ def get(self, request): return response + + class ScheduleDetail(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'delete'] def get(self, request, id): - schedule = get_object_or_404(Schedule, pk=id) - user = request.user - account = Member.objects.get(user=user).account - - if schedule.site.account != account: - data = {'reason': 'you cannot retrieve Schedules of a Site you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - - serializer_context = {'request': request,} - serialized = ScheduleSerializer(schedule, context=serializer_context) - data = serialized.data - record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + response = get_schedule(request, id) + return response def delete(self, request, id): response = delete_schedule(request, id) @@ -258,51 +343,82 @@ def delete(self, request, id): +class ScheduleRun(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post'] -class Automations(APIView): - permission_classes = (AllowAny,) + def post(self, request): + response = run_schedule(request) + return response + + + + +class SchedulesUpdate(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post',] + + def post(self, request): + response = update_many_schedules(request) + return response + + + + +class SchedulesDelete(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post',] + + def post(self, request): + response = delete_many_schedules(request) + return response + + + + +### ------ Begin Alert Views ------ ### + + + + +class Alerts(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'post'] pagination_class = LimitOffsetPagination def post(self, request): - response = create_or_update_automation(request) + response = create_or_update_alert(request) return response def get(self, request): - response = get_automations(request) + response = get_alerts(request) return response -class AutomationDetail(APIView): - permission_classes = (AllowAny,) + + +class AlertDetail(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'delete'] def get(self, request, id): - automation = get_object_or_404(Automation, pk=id) - user = request.user - account = Member.objects.get(user=user).account - - if automation.user != request.user: - data = {'reason': 'you cannot retrieve Automations you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - - serializer_context = {'request': request,} - serialized = AutomationSerializer(automation, context=serializer_context) - data = serialized.data - record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + response = get_alert(request, id) + return response def delete(self, request, id): - response = delete_automation(request, id) + response = delete_alert(request, id) return response +### ------ Begin Report Views ------ ### + + + class Reports(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post', 'get'] def post(self, request): @@ -315,25 +431,14 @@ def get(self, request): + class ReportDetail(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'delete'] def get(self, request, id): - report = get_object_or_404(Report, pk=id) - user = request.user - account = Member.objects.get(user=user).account - - if report.account != account: - data = {'reason': 'you cannot retrieve Reports you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - - serializer_context = {'request': request,} - serialized = ReportSerializer(report, context=serializer_context) - data = serialized.data - record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + response = get_report(request, id) + return response def delete(self, request, id): response = delete_report(request, id) @@ -342,12 +447,23 @@ def delete(self, request, id): +class ExportReport(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post',] + + def post(self, request): + response = export_report(request) + return response + +### ------ Begin Case Views ------ ### + + class Cases(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post', 'get'] def post(self, request): @@ -360,8 +476,9 @@ def get(self, request): + class CasesSearch(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get'] def get(self, request): @@ -370,25 +487,14 @@ def get(self, request): + class CaseDetail(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'delete'] def get(self, request, id): - case = get_object_or_404(Case, pk=id) - user = request.user - account = Member.objects.get(user=user).account - - if case.account != account: - data = {'reason': 'you cannot retrieve Cases you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - - serializer_context = {'request': request,} - serialized = CaseSerializer(case, context=serializer_context) - data = serialized.data - record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + response = get_case(request, id) + return response def delete(self, request, id): response = delete_case(request, id) @@ -396,60 +502,355 @@ def delete(self, request, id): -class Testcases(APIView): - permission_classes = (AllowAny,) + +class AutoCases(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post'] + + def post(self, request): + response = create_auto_cases(request) + return response + + + + +class CopyCases(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post'] + + def post(self, request): + response = copy_case(request) + return response + + + + +class CasesDelete(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post',] + + def post(self, request): + response = delete_many_cases(request) + return response + + + + +class CasesZapier(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_cases_zapier(request) + return response + + + + +### ------ Begin CaseRun Views ------ ### + + + + +class CaseRuns(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['post', 'get'] def post(self, request): - response = create_testcase(request) + response = create_caserun(request) return response def get(self, request): - response = get_testcases(request) + response = get_caseruns(request) return response -class TestcaseDelay(APIView): - permission_classes = (AllowAny,) + +class CaseRunDetail(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get', 'delete'] + + def get(self, request, id): + response = get_caserun(request, id) + return response + + def delete(self, request, id): + response = delete_caserun(request, id) + return response + + + + +class CaseRunsZapier(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_caseruns_zapier(request) + return response + + + + +### ------ Begin Flow Views ------ ### + + + + +class Flows(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post', 'get'] + + def post(self, request): + response = create_or_update_flow(request) + return response + + def get(self, request): + response = get_flows(request) + return response + + + + +class FlowsSearch(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = search_flows(request) + return response + + + + +class FlowDetail(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get', 'delete'] + + def get(self, request, id): + response = get_flow(request, id) + return response + + def delete(self, request, id): + response = delete_flow(request, id) + return response + + + + +class CopyFlows(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post'] + + def post(self, request): + response = copy_flow(request) + return response + + + + +class FlowsDelete(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): - response = create_testcase(request, delay=True) + response = delete_many_flows(request) return response -class TestcaseDetail(APIView): - permission_classes = (AllowAny,) + +class FlowsZapier(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_flows_zapier(request) + return response + + + + +### ------ Begin FlowRun Views ------ ### + + + + +class FlowRuns(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post', 'get'] + + def post(self, request): + response = create_flowrun(request) + return response + + def get(self, request): + response = get_flowruns(request) + return response + + + + +class FlowRunDetail(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'delete'] def get(self, request, id): - testcase = get_object_or_404(Testcase, pk=id) - user = request.user - account = Member.objects.get(user=user).account - - if testcase.account != account: - data = {'reason': 'you cannot retrieve Testcases you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - - serializer_context = {'request': request,} - serialized = TestcaseSerializer(testcase, context=serializer_context) - data = serialized.data - record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + response = get_flowrun(request, id) + return response def delete(self, request, id): - response = delete_testcase(request, id) + response = delete_flowrun(request, id) return response +class FlowRunsZapier(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_flowruns_zapier(request) + return response + + + + +### ------ Begin Issue Views ------ ### + + + + +class Issues(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post', 'get'] + + def post(self, request): + response = create_or_update_issue(request) + return response + + def get(self, request): + response = get_issues(request) + return response + + + + +class IssuesSearch(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = search_issues(request) + return response + + + + +class IssueDetail(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get', 'delete'] + + def get(self, request, id): + response = get_issue(request, id) + return response + + def delete(self, request, id): + response = delete_issue(request, id) + return response + + + + +class IssuesUpdate(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post',] + + def post(self, request): + response = update_many_issues(request) + return response + + + + +class IssuesDelete(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post',] + + def post(self, request): + response = delete_many_issues(request) + return response + + + + +class IssuesZapier(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_issues_zapier(request) + return response + + + + +### ------ Begin Secret Views ------ ### + + + + +class Secrets(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post', 'get'] + + def post(self, request): + response = create_or_update_secret(request) + return response + + def get(self, request): + response = get_secrets(request) + return response + + + + +class SecretDetail(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get', 'delete'] + + def get(self, request, id): + response = get_secret(request, id) + return response + + def delete(self, request, id): + response = delete_secret(request, id) + return response + + + + + +class SecretsAll(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_secrets_all(request) + return response + + + + +### ------ Begin Log Views ------ ### + + + class Logs(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get',] pagination_class = LimitOffsetPagination @@ -458,37 +859,26 @@ def get(self, request): return response + + class LogDetail(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get',] def get(self, request, id): - log = get_object_or_404(Log, pk=id) - if log.user != request.user: - data = {'reason': 'you cannot retrieve Logs you do not own',} - record_api_call(request, data, '403') - return Response(data, status=status.HTTP_403_FORBIDDEN) - - serializer_context = {'request': request,} - serialized = LogSerializer(log, context=serializer_context) - data = serialized.data - return Response(data, status=status.HTTP_200_OK) + response = get_log(request, id) + return response -class HomeStats(APIView): - permission_classes = (AllowAny,) - http_method_names = ['get',] - def get(self, request): - response = get_home_stats(request) - return response +### ------ Begin Process Views ------ ### class Processes(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get'] def get(self, request): @@ -496,47 +886,109 @@ def get(self, request): return response + + class ProcessDetail(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get',] def get(self, request, id): - if not Process.objects.filter(id=id).exists(): - data = {'reason': 'process with that id does not exist',} - record_api_call(request, data, '404') - return Response(data, status=status.HTTP_404_NOT_FOUND) - - proc = Process.objects.get(id=id) - serializer_context = {'request': request,} - serialized = ProcessSerializer(proc, context=serializer_context) - data = serialized.data - record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + response = get_process(request, id) + return response -class WordPressMigrateSite(APIView): + +### ------ Begin Search Views ------ ### + + + + +class Search(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get',] + + def get(self, request): + response = search_resources(request) + return response + + + + +class Device(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get',] + + def get(self, request): + response = get_devices(request) + return response + + + + +### ------ Begin Metrics Views ------ ### + + + + +class HomeMetrics(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get',] + + def get(self, request): + response = get_home_metrics(request) + return response + + + + +class SiteMetrics(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get',] + + def get(self, request): + response = get_site_metrics(request) + return response + + + + +class CeleryMetrics(APIView): + authentication_classes = [] permission_classes = (AllowAny,) - http_method_names = ['post',] + http_method_names = ['get',] - def post(self, request): - response = migrate_site(request, delay=False) + def get(self, request): + response = get_celery_metrics(request) return response -class WordPressMigrateSiteDelay(APIView): - permission_classes = (AllowAny,) + + +### ------ Begin Beta Views ------ ### + + + + +class WordPressMigrateSite(APIView): + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): - response = migrate_site(request, delay=True) + response = migrate_site(request) return response + + class SiteScreenshot(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post',] def post(self, request): response = create_site_screenshot(request) - return response \ No newline at end of file + return response + + + + diff --git a/app/scanerr/__init__.py b/app/cursion/__init__.py similarity index 100% rename from app/scanerr/__init__.py rename to app/cursion/__init__.py diff --git a/app/scanerr/asgi.py b/app/cursion/asgi.py similarity index 75% rename from app/scanerr/asgi.py rename to app/cursion/asgi.py index fa484bf4..80cdefbc 100644 --- a/app/scanerr/asgi.py +++ b/app/cursion/asgi.py @@ -1,5 +1,5 @@ """ -ASGI config for scanerr project. +ASGI config for cursion project. It exposes the ASGI callable as a module-level variable named ``application``. @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scanerr.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cursion.settings') application = get_asgi_application() diff --git a/app/cursion/celery.py b/app/cursion/celery.py new file mode 100644 index 00000000..6ab129bd --- /dev/null +++ b/app/cursion/celery.py @@ -0,0 +1,47 @@ +from __future__ import absolute_import, unicode_literals +from celery import Celery +from celery.signals import worker_shutdown +from django.conf import settings +import cursion, os, time + + + + + + +# setting DJANGO_SETTINGS_MODULE to cursion.settings +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cursion.settings') + +# init celery +app = Celery('cursion') + +# configure namespace +app.config_from_object('django.conf:settings', namespace='CELERY') + +# celery and beat configs +app.conf.update( + task_acks_late=True, + worker_prefetch_multiplier=1, + worker_hijack_root_logger=False, + task_always_eager=False, + task_reject_on_worker_lost=True, + worker_cancel_long_running_tasks_on_connection_loss=True, + worker_max_tasks_per_child=100 +) + +# setting tasks to auto-discover +app.autodiscover_tasks() + +# setting debug +@app.task(bind=False) +def debug_task(self): + print(f'Request: {self.request}') + +# notify of SIGTERM +@worker_shutdown.connect +def on_worker_shutdown(**kwargs): + print(f'- WORKER SHUTTING DOWN - \n{kwargs}') + + + + diff --git a/app/scanerr/settings.py b/app/cursion/settings.py similarity index 73% rename from app/scanerr/settings.py rename to app/cursion/settings.py index 1d4b50c8..17f6e4cf 100644 --- a/app/scanerr/settings.py +++ b/app/cursion/settings.py @@ -1,39 +1,57 @@ """ -Django settings for Scanerr project. +Django settings for Cursion project. -Generated by 'django-admin startproject' using Django 3.2.3. +Generated by 'django-admin startproject' using Django 5.0.6. For more information on this file, see -https://docs.djangoproject.com/en/3.2/topics/settings/ +https://docs.djangoproject.com/en/5.0/topics/settings/ For the full list of settings and their values, see -https://docs.djangoproject.com/en/3.2/ref/settings/ +https://docs.djangoproject.com/en/5.0/ref/settings/ """ from pathlib import Path from datetime import timedelta import os + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent + # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.environ.get('SECRET_KEY') + # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = True if os.environ.get('DEBUG') == 'True' else False + + +# Specifies app and billing behavior +MODE = os.environ.get('MODE') + -ALLOWED_HOSTS = ['*'] +# Network settings +CORS_ORIGIN_ALLOW_ALL = True +DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +ALLOWED_HOSTS = [os.environ.get('DJANGO_ALLOWED_HOSTS')] + + +# URLs & location CLIENT_URL_ROOT = os.environ.get('CLIENT_URL_ROOT') LANDING_URL_ROOT = os.environ.get('LANDING_URL_ROOT') API_URL_ROOT = os.environ.get('API_URL_ROOT') -CORS_ORIGIN_ALLOW_ALL = True -DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 +YELLOWLAB_ROOT = os.environ.get('YELLOWLAB_ROOT') +LIGHTHOUSE_ROOT = os.environ.get('LIGHTHOUSE_ROOT') +LOCATION = os.environ.get('LOCATION') -SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") -# Application definition +# Cursion.landing API KEY +LANDING_API_KEY = os.environ.get('LANDING_API_KEY') + +# Application definition INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -49,7 +67,6 @@ 'markdownify.apps.MarkdownifyConfig', 'storages', ] - MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', @@ -61,9 +78,7 @@ 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'corsheaders.middleware.CorsMiddleware', ] - -ROOT_URLCONF = 'scanerr.urls' - +ROOT_URLCONF = 'cursion.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', @@ -79,8 +94,7 @@ }, }, ] - -WSGI_APPLICATION = 'scanerr.wsgi.application' +WSGI_APPLICATION = 'cursion.wsgi.application' # Database @@ -97,7 +111,6 @@ } - # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ @@ -128,7 +141,6 @@ 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'PAGE_SIZE': 10, } - SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(hours=24), 'REFRESH_TOKEN_LIFETIME': timedelta(hours=36), @@ -137,42 +149,28 @@ # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ - LANGUAGE_CODE = 'en-us' - TIME_ZONE = 'UTC' - USE_I18N = True - USE_L10N = True - USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ -STATIC_URL = '/static/' -STATIC_ROOT = os.path.join(BASE_DIR, "static") +STATIC_URL = '/staticfiles/' +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') -# needed for deployments without nginx -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" -### ONLY NEEDED IF USING DJANGO-STORAGES | remote storage settings for serving static files to django admin ### -# DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' -# STATICFILES_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' -# STORAGE_DOMAIN = os.environ.get('STORAGE_DOMAIN') -# STATIC_ROOT = 'static' -# MEDIA_ROOT = 'media' -# STATIC_URL = f"https://{AWS_S3_ENDPOINT_URL}/{STATIC_ROOT}/" -# MEDIA_URL = f"https://{AWS_S3_ENDPOINT_URL}/{MEDIA_ROOT}/" -# AWS_S3_ENDPOINT_PATH = os.environ.get('AWS_S3_ENDPOINT_PATH') -# AWS_S3_CUSTOM_DOMAIN = os.environ.get('AWS_S3_CUSTOM_DOMAIN') +# Static file service without nginx +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' # Used to authenticate with S3 using 'django-stores' pypi package and 'boto3' AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID') AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY') + # Configure which endpoint to send files to, and retrieve files from. AWS_STORAGE_BUCKET_NAME = os.environ.get('AWS_STORAGE_BUCKET_NAME') AWS_S3_REGION_NAME = os.environ.get('AWS_S3_REGION_NAME') @@ -189,11 +187,12 @@ } +# Redis and Celery Config +CELERY_BROKER_URL = 'redis://redis:6379' -# Redis and Celery Conf -CELERY_BROKER_URL = "redis://redis:6379" -CELERY_RESULT_BACKEND = "redis://redis:6379" +# RabbitMQ and Celery Config +# CELERY_BROKER_URL = 'amqp://rabbitmq' # Default primary key field type @@ -201,7 +200,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -# email +# Email EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST = os.environ.get('EMAIL_HOST') EMAIL_PORT = os.environ.get('EMAIL_PORT') @@ -217,16 +216,65 @@ AUTOMATION_TEMPLATE = os.environ.get('AUTOMATION_TEMPLATE') +# Twilio configs +TWILIO_SID = os.environ.get('TWILIO_SID') +TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_AUTH_TOKEN') +TWILIO_NUMBER = os.environ.get('TWILIO_NUMBER') + -# google oAuth2 +# Google oAuth2 GOOGLE_OAUTH2_CLIENT_ID = os.environ.get('GOOGLE_OAUTH2_CLIENT_ID') GOOGLE_OAUTH2_CLIENT_SECRET = os.environ.get('GOOGLE_OAUTH2_CLIENT_SECRET') -# stripe keys +# Google API key +GOOGLE_CRUX_KEY = os.environ.get('GOOGLE_CRUX_KEY') + + +# OpenAI's GPT API key +GPT_API_KEY = os.environ.get('GPT_API_KEY') + + +# Encryption Key +SECRETS_KEY = os.environ.get('SECRETS_KEY') + + +# Stripe keys if os.environ.get('STRIPE_ENV') == 'prod': STRIPE_PUBLIC = os.environ.get('STRIPE_PUBLIC_LIVE') STRIPE_PRIVATE = os.environ.get('STRIPE_PRIVATE_LIVE') if os.environ.get('STRIPE_ENV') == 'dev': STRIPE_PUBLIC = os.environ.get('STRIPE_PUBLIC_TEST') - STRIPE_PRIVATE = os.environ.get('STRIPE_PRIVATE_TEST') \ No newline at end of file + STRIPE_PRIVATE = os.environ.get('STRIPE_PRIVATE_TEST') + + +# Global configs +CONFIGS = { + 'window_size': '1920,1080', + 'browser': 'chrome', + 'device': 'Windows 10 PC', + 'location': 'us', + 'mask_ids': None, + 'interval': 1, + 'min_wait_time': 3, + 'max_wait_time': 30, + 'timeout': 300, + 'disable_animations': True, + 'auto_height': True, + 'create_issue': True, + 'end_on_fail': True +} + + +# Global Test.threshold +TEST_THRESHOLD = 95 + + +# Global Scan & Test types +TYPES = ['html', 'logs', 'vrt', 'lighthouse', 'yellowlab'] + + +# Global max attempts +MAX_ATTEMPTS = 3 + + diff --git a/app/scanerr/urls.py b/app/cursion/urls.py similarity index 98% rename from app/scanerr/urls.py rename to app/cursion/urls.py index 50d33dcf..4574aef0 100644 --- a/app/scanerr/urls.py +++ b/app/cursion/urls.py @@ -3,6 +3,9 @@ + + + urlpatterns = [ path('admin/', admin.site.urls), path('', include('api.urls')), diff --git a/app/scanerr/wsgi.py b/app/cursion/wsgi.py similarity index 75% rename from app/scanerr/wsgi.py rename to app/cursion/wsgi.py index bc13ad63..7587bfe8 100644 --- a/app/scanerr/wsgi.py +++ b/app/cursion/wsgi.py @@ -1,5 +1,5 @@ """ -WSGI config for scanerr project. +WSGI config for cursion project. It exposes the WSGI callable as a module-level variable named ``application``. @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scanerr.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cursion.settings') application = get_wsgi_application() diff --git a/app/manage.py b/app/manage.py index d4057aa5..00ce80c6 100755 --- a/app/manage.py +++ b/app/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scanerr.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cursion.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/app/scanerr/celery.py b/app/scanerr/celery.py deleted file mode 100644 index 08e5ae7e..00000000 --- a/app/scanerr/celery.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import absolute_import, unicode_literals -from celery import Celery -from django.conf import settings -import scanerr, os - - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scanerr.settings') - -app = Celery('scanerr') -app.config_from_object('django.conf:settings', namespace='CELERY') -app.autodiscover_tasks() - - -@app.task(bind=False) -def debug_task(self): - print('Request: {0!r}'.format(self.request)) - diff --git a/archive/Dockerfile b/archive/Dockerfile new file mode 100644 index 00000000..cf1102d9 --- /dev/null +++ b/archive/Dockerfile @@ -0,0 +1,81 @@ +# pull main python image +FROM python:3.9-slim +ENV PYTHONUNBUFFERED 1 + +# increasing allocated memory to node +ENV NODE_OPTIONS=--max_old_space_size=20000 +ENV NODE_OPTIONS="--max-old-space-size=20000" +ENV GENERATE_SOURCEMAP=false + +# telling Puppeteer to skip installing Chrome +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + +# telling phantomas where Chrome binary is and that we're in docker +ENV PHANTOMAS_CHROMIUM_EXECUTABLE=/usr/bin/chromium +ENV DOCKERIZED=yes + +# Set up the Chromium environment +ENV XDG_CONFIG_HOME=/tmp/.chromium +ENV XDG_CACHE_HOME=/tmp/.chromium + +# create the app user +RUN addgroup --system app && adduser --system app + +# installing python3 & pip +RUN apt-get update && apt-get install -y python3 python3-pip + +# installing system deps +RUN apt-get update && apt-get install -y postgresql postgresql-client gcc \ + gfortran openssl libpq-dev curl libjpeg-dev chromium chromium-driver \ + libfontconfig firefox-esr apt-transport-https software-properties-common + +# installing google-chrome-stable +RUN curl -LO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb +RUN apt-get install -y ./google-chrome-stable_current_amd64.deb +RUN rm google-chrome-stable_current_amd64.deb + +# Download and install Microsoft Edge +RUN curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg && \ + install -o root -g root -m 644 microsoft.gpg /etc/apt/trusted.gpg.d/ && \ + sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > \ + /etc/apt/sources.list.d/microsoft-edge.list' && \ + apt-get update && apt-get install -y microsoft-edge-stable && \ + apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* microsoft.gpg + +# installing node and npm --> n lts +RUN apt-get update && apt-get install nodejs npm -y --no-install-recommends \ + && npm install -g n \ + && n lts + +# cleaning npm +RUN npm cache clean --force + +# installing lighthouse +RUN npm install -g lighthouse lighthouse-plugin-crux lodash yellowlabtools + +# setting --no-sandbox & --disable-dev-shm-usage +RUN chromium --no-sandbox --version +RUN chromium --disable-dev-shm-usage --version + +# installing requirements +COPY ./setup/requirements/requirements.txt /requirements.txt +RUN python3 -m pip install -r /requirements.txt + +# removing chromium config +RUN rm -rf ~/.config/chromium + +# setting working dir +RUN mkdir /app +COPY ./app /app +WORKDIR /app + +# setting ownership +RUN chown -R app:app /app +RUN chown -R app:app /usr/bin/chromium +RUN chown -R app:app /usr/bin/chromedriver +RUN chmod +x /usr/bin/chromedriver + +# staring up services +COPY ./setup/scripts/remote-entrypoint.sh "/remote-entrypoint.sh" +ENTRYPOINT [ "/remote-entrypoint.sh" ] + diff --git a/archive/Dockerfile.local b/archive/Dockerfile.local new file mode 100644 index 00000000..5ac483bf --- /dev/null +++ b/archive/Dockerfile.local @@ -0,0 +1,74 @@ +# pull main ubuntu image and set platform to linux/amd64 +FROM --platform=linux/amd64 ubuntu:latest +ENV DOCKER_DEFAULT_PLATFORM linux/amd64 +ENV PYTHONUNBUFFERED 1 +ENV DEBIAN_FRONTEND noninteractive + +# increasing allocated memory to node +ENV NODE_OPTIONS=--max_old_space_size=7000 +ENV NODE_OPTIONS="--max-old-space-size=7000" + +# telling Puppeteer to skip installing Chrome +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + +# telling phantomas where Chrome binary is and that we're in docker +ENV PHANTOMAS_CHROMIUM_EXECUTABLE=/usr/bin/google-chrome-stable +ENV DOCKERIZED=yes + +# create the app user +RUN groupadd --system app & useradd --system app + +# installing python3 & pip +RUN apt-get update && apt-get install -y python3.10 python3-pip + +# installing system deps +RUN apt-get update && apt-get install -y postgresql postgresql-client gcc \ + gfortran openssl libpq-dev curl libjpeg-dev libfontconfig firefox \ + apt-transport-https software-properties-common libglib2.0-0 libsm6 \ + libxrender1 libxext6 libgl1 + +# installing google-chrome-stable +RUN curl -LO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb +RUN apt-get install -y ./google-chrome-stable_current_amd64.deb +RUN rm google-chrome-stable_current_amd64.deb + +# install microsoft-edge-stable +RUN curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg && \ + install -o root -g root -m 644 microsoft.gpg /etc/apt/trusted.gpg.d/ && \ + sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > \ + /etc/apt/sources.list.d/microsoft-edge.list' && \ + apt-get update && apt-get install -y microsoft-edge-stable && \ + apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* microsoft.gpg + +# installing node and npm +RUN apt-get update && apt-get install nodejs npm -y --no-install-recommends \ + && npm install -g n && n lts + +# begin npm portion +RUN npm cache clean --force + +# installing lighthouse & yellowlabtools +RUN npm install -g lighthouse lighthouse-plugin-crux lodash + +# setting --no-sandbox for Phantomas +RUN google-chrome-stable --no-sandbox --version + +# installing requirements +RUN python3 -m pip install --break-system-packages --upgrade setuptools +COPY ./setup/requirements/requirements-staging.txt /requirements-staging.txt +RUN python3 -m pip install --break-system-packages -r /requirements-staging.txt + +# setting working dir +RUN mkdir /app +COPY ./app /app +WORKDIR /app + +# setting ownership +RUN chown -R app:app /app +RUN chown -R app:app /usr/bin/google-chrome-stable +RUN chown -R app:app /usr/bin/firefox +RUN chown -R app:app /usr/bin/microsoft-edge-stable + +# staring up services +COPY ./setup/scripts/local-entrypoint.sh "/local-entrypoint.sh" +ENTRYPOINT [ "/local-entrypoint.sh" ] diff --git a/archive/docker-compose.dev.yml b/archive/docker-compose.dev.yml new file mode 100644 index 00000000..51c7796d --- /dev/null +++ b/archive/docker-compose.dev.yml @@ -0,0 +1,140 @@ +services: + + + app: + container_name: scanerr-app + hostname: scanerr-app + restart: always + privileged: true + init: true + build: + context: . + dockerfile: Dockerfile + entrypoint: ["/remote-entrypoint.sh", "app"] + expose: + - 8000 + env_file: + - ./env/.env.dev + volumes: + - ./app:/app + - static_volume:/app/static + depends_on: + - db + + + db: + container_name: scanerr-db + hostname: scanerr-db + image: postgres:14-alpine + ports: + - "5432" + env_file: + - ./env/.env.dev + volumes: + - pgdata:/var/lib/postgresql/data + + + redis: + container_name: scanerr-redis + hostname: scanerr-redis + image: redis:alpine + ports: + - "6379" + + + celery: + container_name: scanerr-celery + hostname: scanerr-celery + privileged: true + restart: always + build: + context: . + dockerfile: Dockerfile + entrypoint: ["/remote-entrypoint.sh", "celery"] + env_file: + - ./env/.env.dev + volumes: + - ./app:/scanerr + depends_on: + - redis + - app + - db + + + beat: + container_name: scanerr-beat + hostname: scanerr-beat + privileged: true + restart: always + build: + context: . + dockerfile: Dockerfile + entrypoint: ["/remote-entrypoint.sh", "beat"] + volumes: + - ./app:/scanerr + env_file: + - ./env/.env.dev + depends_on: + - redis + - celery + - app + - db + + + yellowlab: + container_name: yellowlab + hostname: yellowlab + privileged: true + restart: always + image: scanerr/ylt + ports: + - 8383:8383 + depends_on: + - redis + - celery + - app + - db + + + nginx-proxy: + container_name: nginx-proxy + hostname: nginx-proxy + build: nginx + restart: always + ports: + - 443:443 + - 80:80 + volumes: + - static_volume:/app/static + - certs:/etc/nginx/certs + - html:/usr/share/nginx/html + - vhost:/etc/nginx/vhost.d + - /var/run/docker.sock:/tmp/docker.sock:ro + depends_on: + - app + + + nginx-proxy-letsencrypt: + container_name: nginx-proxy-letsencrypt + hostname: nginx-proxy-letsencrypt + image: nginxproxy/acme-companion + environment: + - DEFAULT_EMAIL=youremail@yourdomain.com + - NGINX_PROXY_CONTAINER=nginx-proxy + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - certs:/etc/nginx/certs + - html:/usr/share/nginx/html + - vhost:/etc/nginx/vhost.d + - letsencrypt-acme:/etc/acme.sh + depends_on: + - nginx-proxy + + +volumes: + static_volume: + letsencrypt-acme: + pgdata: + certs: + html: + vhost: \ No newline at end of file diff --git a/archive/remote-entrypoint.sh b/archive/remote-entrypoint.sh new file mode 100755 index 00000000..c11229a2 --- /dev/null +++ b/archive/remote-entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# spin up app in remote env +if [[ $1 == *"app"* ]] +then + python3 manage.py wait_for_db && + python3 manage.py makemigrations --no-input && + python3 manage.py migrate --no-input && + python3 manage.py collectstatic --no-input && + python3 manage.py create_admin && + python3 manage.py driver_test && + gunicorn --timeout 1000 --graceful-timeout 1000 --keep-alive 3 --log-level debug scanerr.wsgi:application --bind 0.0.0.0:8000 +fi + +# spin up celery in remote env +if [[ $1 == *"celery"* ]] +then + python3 manage.py wait_for_db && + echo "pausing for migrations to complete..." && sleep 7s && + celery -A scanerr worker -E --loglevel=info -O fair +fi + +# spin up celery beat in remote env +if [[ $1 == *"beat"* ]] +then + python3 manage.py wait_for_db && + echo "pausing for migrations to complete..." && sleep 7s && + celery -A scanerr beat --scheduler django --loglevel=info +fi diff --git a/requirements.txt b/archive/requirements.txt similarity index 85% rename from requirements.txt rename to archive/requirements.txt index df9fe6b2..46d35ecf 100644 --- a/requirements.txt +++ b/archive/requirements.txt @@ -1,9 +1,10 @@ amqp==5.0.6 asgiref==3.3.4 +beautifulsoup4==4.12.2 billiard==3.6.4.0 boto3==1.20.32 celery==5.1.0 -certifi==2021.5.30 +certifi==2023.7.22 chardet==4.0.0 click==7.1.2 click-didyoumean==0.0.3 @@ -21,28 +22,29 @@ docker==5.0.0 gunicorn==20.1.0 humanize==3.7.0 idna==2.10 +imutils==0.5.4 kombu==5.1.0 Markdown==3.3.4 numpy==1.22.3 +openai==1.35.14 opencv-python==4.5.5.64 -Pillow==9.0.0 +Pillow==9.0.1 prometheus-client==0.8.0 prompt-toolkit==3.0.18 psycopg2==2.8.6 pyjwt==2.1.0 -pyppeteer==1.0.2 pytz==2021.1 redis==3.5.3 requests==2.25.1 reportlab==3.6.6 +scikit-image==0.21.0 scipy==1.8.0 -selenium==4.1.3 +selenium==4.18.1 sendgrid==6.9.7 -sewar==0.4.4 six==1.16.0 slack-sdk==3.11.2 sqlparse==0.4.1 -stripe==2.60.0 +stripe==8.0.0 tornado==6.1 twilio==7.3.0 urllib3==1.26.5 diff --git a/commands b/commands deleted file mode 100644 index a6764d18..00000000 --- a/commands +++ /dev/null @@ -1,22 +0,0 @@ -### spins up container on localhost ### -docker compose up --build - -### spins down container on localhost ### -docker compose down - - - -### spins up the container for production ### -docker compose -f docker-compose.prod.yml up -d --build - -### spins down the container ### -docker compose -f docker-compose.prod.yml down - - - -### spins up the container for development ### -docker compose -f docker-compose.dev.yml up -d --build - -### spins down the container ### -docker compose -f docker-compose.dev.yml down - diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f09772ed..4f13a2d3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,32 +1,45 @@ -version: '3' - services: - app: + + + client: + container_name: cursion-client + hostname: cursion-client + image: cursiondev/client + platform: linux/amd64 + pull_policy: always + env_file: + - ./env/.env.client.dev + expose: + - "8080" + + + server: + container_name: cursion-server + hostname: cursion-server + platform: linux/amd64 privileged: true + restart: always init: true build: context: . - dockerfile: Dockerfile.prod - volumes: - - ./app:/app - - static_volume:/app/static - command: > - sh -c "python3 manage.py makemigrations --no-input && - python3 manage.py migrate --no-input && - python3 manage.py collectstatic --no-input && - python3 manage.py wait_for_db && - python3 manage.py create_admin && - python3 manage.py driver_s_test && - python3 manage.py driver_p_test && - gunicorn --timeout 1000 --graceful-timeout 1000 --keep-alive 3 --log-level debug scanerr.wsgi:application --bind 0.0.0.0:8000" + dockerfile: Dockerfile + entrypoint: ["/entrypoint.sh", "server", "remote"] expose: - - 8000 + - "8000" env_file: - ./env/.env.dev + volumes: + - server:/app + - static_volume:/app/staticfiles + depends_on: + - db - + db: - image: postgres:10-alpine + container_name: cursion-db + hostname: cursion-db + image: postgres:14-alpine + platform: linux/amd64 ports: - "5432" env_file: @@ -36,63 +49,115 @@ services: redis: + container_name: cursion-redis + hostname: cursion-redis image: redis:alpine + platform: linux/amd64 ports: - "6379" celery: + container_name: cursion-celery + hostname: cursion-celery + platform: linux/amd64 + privileged: true + restart: always + build: + context: . + dockerfile: Dockerfile + entrypoint: ["/entrypoint.sh", "celery"] + env_file: + - ./env/.env.dev + volumes: + - celery:/app + depends_on: + - redis + - server + - db + + + beat: + container_name: cursion-beat + hostname: cursion-beat + platform: linux/amd64 privileged: true restart: always build: context: . - dockerfile: Dockerfile.prod - command: celery -A scanerr worker --beat --scheduler django --loglevel=info + dockerfile: Dockerfile + entrypoint: ["/entrypoint.sh", "beat"] volumes: - - ./app:/scanerr + - beat:/app env_file: - ./env/.env.dev depends_on: - redis - - app + - celery + - server + - db + + + yellowlab: + container_name: yellowlab + hostname: yellowlab + privileged: true + restart: always + image: cursiondev/ylt + platform: linux/amd64 + ports: + - "8383:8383" + depends_on: + - redis + - celery + - server - db nginx-proxy: container_name: nginx-proxy - build: nginx + hostname: nginx-proxy + image: cursiondev/nginx + platform: linux/amd64 restart: always ports: - - 443:443 - - 80:80 + - "443:443" + - "80:80" volumes: - - static_volume:/app/static + - static_volume:/app/staticfiles - certs:/etc/nginx/certs - html:/usr/share/nginx/html - vhost:/etc/nginx/vhost.d - /var/run/docker.sock:/tmp/docker.sock:ro depends_on: - - app + - server + - client nginx-proxy-letsencrypt: - image: nginxproxy/acme-companion # LEGACY -> jrcs/letsencrypt-nginx-proxy-companion + container_name: nginx-proxy-letsencrypt + hostname: nginx-proxy-letsencrypt + image: nginxproxy/acme-companion + platform: linux/amd64 env_file: - - ./env/.env.prod.proxy-companion + - ./env/.env.dev volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - certs:/etc/nginx/certs - html:/usr/share/nginx/html - vhost:/etc/nginx/vhost.d + - /var/run/docker.sock:/tmp/docker.sock:ro - letsencrypt-acme:/etc/acme.sh depends_on: - nginx-proxy volumes: + server: static_volume: + celery: + beat: letsencrypt-acme: + pgdata: certs: html: - vhost: - pgdata: \ No newline at end of file + vhost: \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index cc375e35..00000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,87 +0,0 @@ -version: '3' - -services: - app: - restart: always - privileged: true - init: true - build: - context: . - dockerfile: Dockerfile.prod - # image: landonr/scanerr-server - volumes: - - ./app:/app - - static_volume:/app/static - command: > - sh -c "python3 manage.py makemigrations --no-input && - python3 manage.py migrate --no-input && - python3 manage.py collectstatic --no-input && - python3 manage.py wait_for_db && - python3 manage.py create_admin && - python3 manage.py driver_s_test && - python3 manage.py driver_p_test && - gunicorn --timeout 1000 --graceful-timeout 1000 --keep-alive 3 --log-level debug scanerr.wsgi:application --bind 0.0.0.0:8000" - expose: - - 8000 - env_file: - - ./env/.env.prod - - redis: - image: redis:alpine - ports: - - "6379" - - celery: - privileged: true - restart: always - build: - context: . - dockerfile: Dockerfile.prod - # image: landonr/scanerr-server - command: celery -A scanerr worker --beat --scheduler django --loglevel=info - volumes: - - ./app:/scanerr - env_file: - - ./env/.env.prod - depends_on: - - redis - - app - - - nginx-proxy: - container_name: nginx-proxy - build: nginx - restart: always - ports: - - 443:443 - - 80:80 - volumes: - - static_volume:/app/static - - certs:/etc/nginx/certs - - html:/usr/share/nginx/html - - vhost:/etc/nginx/vhost.d - - /var/run/docker.sock:/tmp/docker.sock:ro - depends_on: - - app - - - nginx-proxy-letsencrypt: - image: nginxproxy/acme-companion # LEGACY -> jrcs/letsencrypt-nginx-proxy-companion - env_file: - - ./env/.env.prod.proxy-companion - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - - certs:/etc/nginx/certs - - html:/usr/share/nginx/html - - vhost:/etc/nginx/vhost.d - - letsencrypt-acme:/etc/acme.sh - depends_on: - - nginx-proxy - - -volumes: - static_volume: - letsencrypt-acme: - certs: - html: - vhost: \ No newline at end of file diff --git a/docker-compose.remote.yml b/docker-compose.remote.yml new file mode 100644 index 00000000..10cc0c87 --- /dev/null +++ b/docker-compose.remote.yml @@ -0,0 +1,130 @@ +services: + + + server: + container_name: cursion-server + hostname: cursion-server + image: cursiondev/server + platform: linux/amd64 + pull_policy: always + restart: always + privileged: true + init: true + entrypoint: ["/entrypoint.sh", "server", "remote"] + expose: + - "8000" + env_file: + - ./env/.env.remote + volumes: + - server:/app + - static_volume:/app/staticfiles + + + redis: + container_name: cursion-redis + hostname: cursion-redis + image: redis:alpine + platform: linux/amd64 + ports: + - "6379" + + + celery: + container_name: cursion-celery + hostname: cursion-celery + image: cursiondev/server + platform: linux/amd64 + pull_policy: always + privileged: true + restart: always + entrypoint: ["/entrypoint.sh", "celery"] + env_file: + - ./env/.env.remote + volumes: + - celery:/app + depends_on: + - redis + - server + + + beat: + container_name: cursion-beat + hostname: cursion-beat + image: cursiondev/server + platform: linux/amd64 + privileged: true + pull_policy: always + restart: always + entrypoint: ["/entrypoint.sh", "beat"] + volumes: + - beat:/app + env_file: + - ./env/.env.remote + depends_on: + - redis + - celery + - server + + + yellowlab: + container_name: yellowlab + hostname: yellowlab + image: cursiondev/ylt + pull_policy: always + platform: linux/amd64 + privileged: true + restart: always + ports: + - "8383:8383" + depends_on: + - redis + - celery + - server + + + nginx-proxy: + container_name: nginx-proxy + hostname: nginx-proxy + image: cursiondev/nginx + pull_policy: always + platform: linux/amd64 + restart: always + ports: + - "443:443" + - "80:80" + volumes: + - static_volume:/app/staticfiles + - certs:/etc/nginx/certs + - html:/usr/share/nginx/html + - vhost:/etc/nginx/vhost.d + - /var/run/docker.sock:/tmp/docker.sock:ro + depends_on: + - server + + + nginx-proxy-letsencrypt: + container_name: nginx-proxy-letsencrypt + hostname: nginx-proxy-letsencrypt + image: nginxproxy/acme-companion + platform: linux/amd64 + env_file: + - ./env/.env.remote + volumes: + - certs:/etc/nginx/certs + - html:/usr/share/nginx/html + - vhost:/etc/nginx/vhost.d + - /var/run/docker.sock:/tmp/docker.sock:ro + - letsencrypt-acme:/etc/acme.sh + depends_on: + - nginx-proxy + + +volumes: + server: + static_volume: + celery: + beat: + letsencrypt-acme: + certs: + html: + vhost: \ No newline at end of file diff --git a/docker-compose.stage.yml b/docker-compose.stage.yml new file mode 100644 index 00000000..17445786 --- /dev/null +++ b/docker-compose.stage.yml @@ -0,0 +1,20 @@ +services: + + + server: + container_name: cursion-server + hostname: cursion-server + platform: linux/amd64 + privileged: true + init: true + restart: no + build: + context: . + dockerfile: Dockerfile + ports: + - "8000:8000" + entrypoint: ["/entrypoint.sh", "server", "stage"] + env_file: + - ./env/.env.stage + volumes: + - ./app:/app diff --git a/docker-compose.yml b/docker-compose.yml index a8f492c0..c7736c43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,62 +1,103 @@ -version: '3' services: - app: + + server: + container_name: cursion-server + hostname: cursion-server + platform: linux/amd64 privileged: true + restart: no init: true - restart: always build: context: . - dockerfile: Dockerfile.prod - # image: landonr/scanerr-server + dockerfile: Dockerfile ports: - "8000:8000" - volumes: - - ./app:/app - command: > - sh -c "python3 manage.py makemigrations --no-input && - python3 manage.py migrate --no-input && - python3 manage.py collectstatic --no-input && - python3 manage.py wait_for_db && - python3 manage.py create_admin && - python3 manage.py driver_s_test && - python3 manage.py driver_p_test && - python3 manage.py runserver 0.0.0.0:8000" + entrypoint: ["/entrypoint.sh", "server", "local"] env_file: - ./env/.env.local + volumes: + - server:/app depends_on: - db + db: - image: postgres:10-alpine - ports: - - "5432" + container_name: cursion-db + hostname: cursion-db + image: postgres:14-alpine env_file: - - ./env/.env.dev + - ./env/.env.local volumes: - pgdata:/var/lib/postgresql/data + redis: + container_name: cursion-redis + hostname: cursion-redis image: redis:alpine ports: - "6379" - + + celery: + container_name: cursion-celery + hostname: cursion-celery + platform: linux/amd64 + privileged: true + restart: no + build: + context: . + dockerfile: Dockerfile + entrypoint: ["/entrypoint.sh", "celery"] + volumes: + - celery:/app + env_file: + - ./env/.env.local + depends_on: + - redis + - server + - db + + + beat: + container_name: cursion-beat + hostname: cursion-beat + platform: linux/amd64 privileged: true - restart: always + restart: no build: context: . - dockerfile: Dockerfile.prod - # image: landonr/scanerr-server - command: celery -A scanerr worker --beat --scheduler django --loglevel=info + dockerfile: Dockerfile + entrypoint: ["/entrypoint.sh", "beat"] volumes: - - ./app:/scanerr + - beat:/app env_file: - ./env/.env.local depends_on: + - redis + - celery + - server - db + + + yellowlab: + container_name: yellowlab + hostname: yellowlab + privileged: true + restart: no + image: cursiondev/ylt + ports: + - 8383:8383 + depends_on: - redis - - app + - celery + - server + - db + volumes: pgdata: + server: + celery: + beat: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 00000000..19579682 --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -0,0 +1,146 @@ +services: + + + client: + image: cursiondev/client + env_file: + - .env.prod + ports: + - "3000:3000" + + + app: + container_name: cursion-app + hostname: cursion-app + restart: always + platform: linux/amd64 + privileged: true + init: true + image: cursiondev/server + entrypoint: ["/entrypoint.sh", "app", "remote"] + expose: + - 8000 + env_file: + - .env.prod + volumes: + - app:/app + - static_volume:/app/static + depends_on: + - db + + + db: + container_name: cursion-db + hostname: cursion-db + image: postgres:14-alpine + ports: + - "5432" + env_file: + - .env.prod + volumes: + - pgdata:/var/lib/postgresql/data + + + redis: + container_name: cursion-redis + hostname: cursion-redis + image: redis:alpine + ports: + - "6379" + + + celery: + container_name: cursion-celery + hostname: cursion-celery + privileged: true + restart: always + image: cursiondev/server + entrypoint: ["/entrypoint.sh", "celery"] + env_file: + - .env.prod + volumes: + - celery:/app + depends_on: + - redis + - app + - db + + + beat: + container_name: cursion-beat + hostname: cursion-beat + privileged: true + restart: always + image: cursiondev/server + entrypoint: ["/entrypoint.sh", "beat"] + volumes: + - beat:/app + env_file: + - .env.prod + depends_on: + - redis + - celery + - app + - db + + + yellowlab: + container_name: yellowlab + hostname: yellowlab + privileged: true + restart: always + image: cursiondev/ylt + ports: + - 8383:8383 + depends_on: + - redis + - celery + - app + - db + + + nginx-proxy: + container_name: nginx-proxy + hostname: nginx-proxy + image: cursiondev/nginx + restart: always + ports: + - 443:443 + - 80:80 + volumes: + - static_volume:/app/static + - certs:/etc/nginx/certs + - html:/usr/share/nginx/html + - vhost:/etc/nginx/vhost.d + - /var/run/docker.sock:/tmp/docker.sock:ro + depends_on: + - app + + + nginx-proxy-letsencrypt: + container_name: nginx-proxy-letsencrypt + hostname: nginx-proxy-letsencrypt + image: nginxproxy/acme-companion + environment: + - DEFAULT_EMAIL=youremail@yourdomain.com + - NGINX_PROXY_CONTAINER=nginx-proxy + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - certs:/etc/nginx/certs + - html:/usr/share/nginx/html + - vhost:/etc/nginx/vhost.d + - letsencrypt-acme:/etc/acme.sh + depends_on: + - nginx-proxy + + +volumes: + app: + celery: + beat: + static_volume: + letsencrypt-acme: + pgdata: + certs: + html: + vhost: \ No newline at end of file diff --git a/env/.env.dev.example b/env/.env.dev.example deleted file mode 100644 index f05120b0..00000000 --- a/env/.env.dev.example +++ /dev/null @@ -1,88 +0,0 @@ -# django -SECRET_KEY = ask-for-this-or-generate-yourself -CLIENT_URL_ROOT = https://app.example.io # example -API_URL_ROOT = https://api.example.io # example -LETSENCRYPT_HOST = api.example.io # example -VIRTUAL_HOST = api.example.io # example -VIRTUAL_PORT = 8000 -DJANGO_ALLOWED_HOSTS = * - - -# admin credentials -ADMIN_USER = fake # example -ADMIN_PASS = dontTryIt1234 # example -ADMIN_EMAIL = fake@example.com # example - - -# email credentials -EMAIL_HOST = smtp.gmail.com -EMAIL_PORT = 587 -EMAIL_USE_TLS = True -EMAIL_HOST_USER = fake@example.com # example -EMAIL_HOST_PASSWORD = 1234456677888 # example - - -# database -DB_HOST=db -DB_NAME=app -DB_USER=postgres -DB_PASS=supersecretpassword -POSTGRES_DB=app -POSTGRES_USER=postgres -POSTGRES_PASSWORD=supersecretpassword - - -# paths -CHROMEDRIVER = /usr/bin/chromedriver -GOOGLECHROME = /usr/bin/google-chrome -CHROMIUM = /usr/bin/chromium - - -# stripe keys -STRIPE_PUBLIC_TEST = -STRIPE_PRIVATE_TEST = -STRIPE_PUBLIC_LIVE = -STRIPE_PRIVATE_LIVE = -STRIPE_ENV = dev - - -# google keys -GOOGLE_CRUX_KEY = - - -# OAuth keys -GOOGLE_OAUTH2_CLIENT_ID = -GOOGLE_OAUTH2_CLIENT_SECRET = - - -# twilio credentials -TWILIO_SID = -TWILIO_AUTH_TOKEN = -TWILIO_NUMBER = - - -# sendgrid configs -SENDGRID_API_KEY = -DEFAULT_TEMPLATE = -DEFAULT_TEMPLATE_NO_BUTTON = -AUTOMATION_TEMPLATE = - - -# slack credentials -SLACK_APP_ID = -SLACK_CLIENT_ID = -SLACK_CLIENT_SECRET = -SLACK_SIGNING_SECRET = -SLACK_VERIFICATION_TOKEN = -SLACK_BOT_TOKEN = - - -# s3 remote storage credentials -AWS_ACCESS_KEY_ID = -AWS_SECRET_ACCESS_KEY = -AWS_STORAGE_BUCKET_NAME = storage-scanerr # example -AWS_S3_REGION_NAME = sfo3 # example -AWS_S3_ENDPOINT_URL = https://sfo3.digitaloceanspaces.com # example -AWS_S3_URL_PATH = https://storage-scanerr.sfo3.digitaloceanspaces.com # example -AWS_LOCATION = static -AWS_DEFAULT_ACL = public-read \ No newline at end of file diff --git a/env/.env.local.example b/env/.env.local.example deleted file mode 100644 index 40b50b9c..00000000 --- a/env/.env.local.example +++ /dev/null @@ -1,84 +0,0 @@ -# high level django configs -SECRET_KEY = ask-for-this -CLIENT_URL_ROOT = http://localhost:3000 -CLIENT_URL_ROOT = http://localhost:3000 -API_URL_ROOT = http://localhost:8000 -DJANGO_ALLOWED_HOSTS = * - - -# admin credentials -ADMIN_USER = fake # example -ADMIN_PASS = dontTryIt1234 # example -ADMIN_EMAIL = fake@example.com # example - - -# email credentials -EMAIL_HOST = smtp.gmail.com -EMAIL_PORT = 587 -EMAIL_USE_TLS = True -EMAIL_HOST_USER = fake@example.com # example -EMAIL_HOST_PASSWORD = 1234456677888 # example - - -# database configs -DB_HOST=db -DB_NAME=app -DB_USER=postgres -DB_PASS=supersecretpassword -POSTGRES_DB=app -POSTGRES_USER=postgres -POSTGRES_PASSWORD=supersecretpassword - - -# paths -CHROMEDRIVER = /usr/bin/chromedriver -GOOGLECHROME = /usr/bin/google-chrome -CHROMIUM = /usr/bin/chromium - - -# stripe keys -STRIPE_PUBLIC_TEST = -STRIPE_PRIVATE_TEST = - - -# google keys -GOOGLE_CRUX_KEY = - - -# OAuth keys -GOOGLE_OAUTH2_CLIENT_ID = -GOOGLE_OAUTH2_CLIENT_SECRET = - - -# twilio credentials -TWILIO_SID = -TWILIO_AUTH_TOKEN = -TWILIO_NUMBER = - - -# sendgrid configs -SENDGRID_API_KEY = -DEFAULT_TEMPLATE = -DEFAULT_TEMPLATE_NO_BUTTON = -AUTOMATION_TEMPLATE = - - - -# slack credentials -SLACK_APP_ID = -SLACK_CLIENT_ID = -SLACK_CLIENT_SECRET = -SLACK_SIGNING_SECRET = -SLACK_VERIFICATION_TOKEN = -SLACK_BOT_TOKEN = - - -# s3 remote storage credentials -AWS_ACCESS_KEY_ID = -AWS_SECRET_ACCESS_KEY = -AWS_STORAGE_BUCKET_NAME = storage-scanerr # example -AWS_S3_REGION_NAME = sfo3 # example -AWS_S3_ENDPOINT_URL = https://sfo3.digitaloceanspaces.com # example -AWS_S3_URL_PATH = https://storage-scanerr.sfo3.digitaloceanspaces.com # example -AWS_LOCATION = static -AWS_DEFAULT_ACL = public-read \ No newline at end of file diff --git a/env/.env.prod.example b/env/.env.prod.example deleted file mode 100644 index 96d89f7c..00000000 --- a/env/.env.prod.example +++ /dev/null @@ -1,86 +0,0 @@ -# high level django configs -SECRET_KEY = ask-for-this-or-generate-yourself -CLIENT_URL_ROOT = https://app.example.io # example -LANDING_URL_ROOT = https://example.io # example -API_URL_ROOT = https://api.example.io # example -LETSENCRYPT_HOST = api.example.io # example -DJANGO_ALLOWED_HOSTS = * - - -# admin credentials -ADMIN_USER = fake # example -ADMIN_PASS = dontTryIt1234 # example -ADMIN_EMAIL = fake@example.com # example - - -# email credentials -EMAIL_HOST = smtp.gmail.com -EMAIL_PORT = 587 -EMAIL_USE_TLS = True -EMAIL_HOST_USER = fake@example.com # example -EMAIL_HOST_PASSWORD = 1234456677888 # example - - -# database configs -DB_NAME = defaultdb # example -DB_USER = doadmin # example -DB_PASS = -DB_PORT = -DB_HOST = db-273428-user-ndjweodi2.b.db.ondigitalocean.com # example - - -# paths -CHROMEDRIVER = /usr/bin/chromedriver -GOOGLECHROME = /usr/bin/google-chrome -CHROMIUM = /usr/bin/chromium - - -# stripe keys -STRIPE_PUBLIC_TEST = -STRIPE_PRIVATE_TEST = -STRIPE_PUBLIC_LIVE = -STRIPE_PRIVATE_LIVE = -STRIPE_ENV = prod - - -# google keys -GOOGLE_CRUX_KEY = - - -# OAuth keys -GOOGLE_OAUTH2_CLIENT_ID = -GOOGLE_OAUTH2_CLIENT_SECRET = - - -# twilio credentials -TWILIO_SID = -TWILIO_AUTH_TOKEN = -TWILIO_NUMBER = - - -# sendgrid configs -SENDGRID_API_KEY = -DEFAULT_TEMPLATE = -DEFAULT_TEMPLATE_NO_BUTTON = -AUTOMATION_TEMPLATE = - - - -# slack credentials -SLACK_APP_ID = -SLACK_CLIENT_ID = -SLACK_CLIENT_SECRET = -SLACK_SIGNING_SECRET = -SLACK_VERIFICATION_TOKEN = -SLACK_BOT_TOKEN = - - -# s3 remote storage credentials -AWS_ACCESS_KEY_ID = -AWS_SECRET_ACCESS_KEY = -AWS_STORAGE_BUCKET_NAME = storage-scanerr # example -AWS_S3_REGION_NAME = sfo3 # example -AWS_S3_ENDPOINT_URL = https://sfo3.digitaloceanspaces.com # example -AWS_S3_URL_PATH = https://storage-scanerr.sfo3.digitaloceanspaces.com # example -AWS_LOCATION = static -AWS_DEFAULT_ACL = public-read \ No newline at end of file diff --git a/env/.env.prod.proxy-companion b/env/.env.prod.proxy-companion deleted file mode 100644 index 085d84bf..00000000 --- a/env/.env.prod.proxy-companion +++ /dev/null @@ -1,2 +0,0 @@ -DEFAULT_EMAIL=youremail@yourdomain.com -NGINX_PROXY_CONTAINER=nginx-proxy \ No newline at end of file diff --git a/k8s/kubernetes-notes.md b/k8s/kubernetes-notes.md deleted file mode 100644 index 336cb823..00000000 --- a/k8s/kubernetes-notes.md +++ /dev/null @@ -1,84 +0,0 @@ -### Create k8s files in yaml (kompose) -```shell -kompose convert -f docker-compose.yml -o ./k8s -``` - -### Build k8s -```shell -kubectl apply -f ./k8s/k8s-local.yaml -``` - -### Delete k8s -```shell -kubectl delete -f ./k8s/k8s-local.yaml -``` - -### List containers -```shell -kubectl get pod -``` - -### List pods with IPs -```shell -kubectl get pod -o wide -``` - -### To get all creation events for debugging: -```shell -kubectl get events --sort-by=.metadata.creationTimestamp -``` - -### SSH into container: -```shell -kubectl exec -it celery-849f76858b-bvmqg -- /bin/sh -``` - -### Creating secrets for docker: -```shell -kubectl create secret docker-registry regcred --docker-server=https://index.docker.io/v1/ --docker-username=landonr --docker-password=Ljr500103! --docker-email=l.rodden52@gmail.com -``` - -#### - Then add this to both celery and app containers: -```yaml -spec: - imagePullSecrets: - - name: regcred -``` - -### Start and Stop minikube -```shell -minikube start -minikube stop -``` - -### Port Forwarding for app -```shell -kubectl port-forward service/app-service 8000:8000 -``` - - - -## Setps to Deploy localy -1. ensure minikube is running - - ``` minikube status ``` -2. create secrets for app image pull from docker - - ``` kubectl create secret docker-registry regcred --docker-server=https://index.docker.io/v1/ --docker-username=landonr --docker-password=Ljr500103! --docker-email=l.rodden52@gmail.com ``` -3. build db-configs-configs - - ``` kubectl apply db-configs.yaml ``` -4. build db-deployment - - ``` kubectl apply db-deployment.yaml ``` -5. build redis-deployment -6. get pod ip of db-deployment - - ``` kubectl get pod --template '{{.status.podIP}}' ``` - - or ``` kubectl get pod -o wide ``` -7. copy ip and paste into app-configs-configs for field "DB_HOST" -8. build app-configs - - ``` kubectl apply app-config.yaml ``` -9. build app-deployment - - ``` kubectl apply db-deployment.yaml ``` -10. build celery-deployment - - ``` kubectl apply db-deployment.yaml ``` -11. port forwarding to app deployment - - ``` kubectl port-forward service/app-service 8000:8000 ``` - - \ No newline at end of file diff --git a/k8s/local/app-deployment.yaml b/k8s/local/app-deployment.yaml index 36be9020..1b2dd72c 100644 --- a/k8s/local/app-deployment.yaml +++ b/k8s/local/app-deployment.yaml @@ -16,7 +16,7 @@ spec: - name: regcred containers: - name: app - image: landonr/scanerr-server + image: cursion/server:latest imagePullPolicy: IfNotPresent ports: - containerPort: 8000 @@ -29,7 +29,7 @@ spec: python3 manage.py migrate --no-input && python3 manage.py collectstatic --no-input && python3 manage.py create_admin && - python3 manage.py driver_s_test && + python3 manage.py driver_test && python3 manage.py driver_p_test && python3 manage.py runserver 0.0.0.0:8000 envFrom: diff --git a/k8s/local/celery-deployment.yaml b/k8s/local/celery-deployment.yaml index 64ed79e9..060a2a89 100644 --- a/k8s/local/celery-deployment.yaml +++ b/k8s/local/celery-deployment.yaml @@ -16,9 +16,9 @@ spec: - name: regcred containers: - name: celery - image: landonr/scanerr-server + image: cursion/server:latest imagePullPolicy: IfNotPresent - command: ["celery", "-A", "scanerr", "worker", "--beat", "--scheduler", "django", "--loglevel=info"] + command: ["celery", "-A", "cursion", "worker", "--beat", "--scheduler", "django", "--loglevel=info"] envFrom: - configMapRef: name: app-configs diff --git a/k8s/prod/app-cert-issuer.yaml b/k8s/prod/app-cert-issuer.yaml new file mode 100644 index 00000000..9c24665d --- /dev/null +++ b/k8s/prod/app-cert-issuer.yaml @@ -0,0 +1,19 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-nginx + namespace: default +spec: + acme: + # The ACME server URL + server: https://acme-v02.api.letsencrypt.org/directory # https://acme-staging-v02.api.letsencrypt.org/directory + # Email address used for ACME registration + email: hello@cursion.dev + # Name of a secret used to store the ACME account private key + privateKeySecretRef: + name: letsencrypt-nginx-private-key + # Enable the HTTP-01 challenge provider + solvers: + - http01: + ingress: + class: nginx diff --git a/k8s/prod/app-configs-example.yaml b/k8s/prod/app-configs-example.yaml new file mode 100644 index 00000000..1291d535 --- /dev/null +++ b/k8s/prod/app-configs-example.yaml @@ -0,0 +1,72 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-configs +data: + # django + SECRET_KEY : "ask-for-this-or-generate-yourself" + CLIENT_URL_ROOT : "https://app.yourdomain.com" + API_URL_ROOT : "https://api.yourdomain.com" + YELLOWLAB_ROOT : "http://ylt-service" + LIGHTHOUSE_ROOT : "https://www.googleapis.com/pagespeedonline/v5/runPagespeed" + LANDING_API_KEY : "" + LANDING_URL_ROOT : "https://yourdomain.com" + LETSENCRYPT_HOST : "api.yourdomain.com" + VIRTUAL_HOST : "api.yourdomain.com" + VIRTUAL_PORT : "8000" + DJANGO_ALLOWED_HOSTS : "*" + # admin credentials + ADMIN_USER : "admin_user" + ADMIN_PASS : "f4k3P455w0rd" + ADMIN_EMAIL : "your@email.com" + # email credentials + EMAIL_HOST : "smtp.gmail.com" + EMAIL_PORT : "587" + EMAIL_USE_TLS : "True" + EMAIL_HOST_USER : "your@email.com" + EMAIL_HOST_PASSWORD : "your-email-password" + # database + DB_HOST : "" + DB_NAME : "k8s-pool" + DB_PASS : "" + DB_PORT : "25061" + DB_USER : "" + # paths + CHROMEDRIVER : "/usr/bin/chromedriver" + CHROME_BROWSER : "/usr/bin/chromium" + # stripe keys + STRIPE_PUBLIC_TEST : "pk_test_" + STRIPE_PRIVATE_TEST : "sk_test_" + STRIPE_PUBLIC_LIVE : "pk_live_" + STRIPE_PRIVATE_LIVE : "sk_live_" + STRIPE_ENV : "prod" + # google keys + GOOGLE_CRUX_KEY : "" + # OAuth keys + GOOGLE_OAUTH2_CLIENT_ID : "" + GOOGLE_OAUTH2_CLIENT_SECRET : "" + # twilio credentials + TWILIO_SID : "" + TWILIO_AUTH_TOKEN : "" + TWILIO_NUMBER : "+" + # sendgrid configs + SENDGRID_API_KEY : "" + DEFAULT_TEMPLATE : "" + DEFAULT_TEMPLATE_NO_BUTTON : "" + AUTOMATION_TEMPLATE : "" + # slack credentials + SLACK_APP_ID : "" + SLACK_CLIENT_ID : "" + SLACK_CLIENT_SECRET : "" + SLACK_SIGNING_SECRET : "" + SLACK_VERIFICATION_TOKEN : "" + SLACK_BOT_TOKEN : "" + # s3 remote storage credentials + AWS_ACCESS_KEY_ID : "" + AWS_SECRET_ACCESS_KEY : "" + AWS_STORAGE_BUCKET_NAME : "" + AWS_S3_REGION_NAME : "sfo3" + AWS_S3_ENDPOINT_URL : "https://sfo3.digitaloceanspaces.com" + AWS_S3_URL_PATH : "https://.sfo3.digitaloceanspaces.com" + AWS_LOCATION : "static" + AWS_DEFAULT_ACL : "public-read" diff --git a/k8s/prod/app-deployment.yaml b/k8s/prod/app-deployment.yaml index c91eb55a..1cb733b6 100644 --- a/k8s/prod/app-deployment.yaml +++ b/k8s/prod/app-deployment.yaml @@ -2,58 +2,56 @@ apiVersion: apps/v1 kind: Deployment metadata: name: app-deployment + labels: + deployment: app spec: - replicas: 1 + replicas: 2 selector: matchLabels: - app: app + app: app-deployment template: metadata: labels: - app: app + app: app-deployment spec: imagePullSecrets: - name: regcred containers: - - name: app - image: landonr/scanerr-server - imagePullPolicy: IfNotPresent + - name: cursion-server + image: # cursiondev/server:cd77834 # + imagePullPolicy: Always ports: - containerPort: 8000 - command: - - "sh" - - "-c" - - > - python3 manage.py wait_for_db && - python3 manage.py makemigrations --no-input && - python3 manage.py migrate --no-input && - python3 manage.py collectstatic --no-input && - python3 manage.py create_admin && - python3 manage.py driver_s_test && - python3 manage.py driver_p_test && - gunicorn --timeout 1000 --graceful-timeout 1000 --keep-alive 3 --log-level debug scanerr.wsgi:application --bind 0.0.0.0:8000" + command: ["/entrypoint.sh", "server", "remote"] envFrom: - configMapRef: name: app-configs + env: + - name: THIS_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name resources: limits: + cpu: "2" + memory: "4Gi" + requests: cpu: "1" memory: "1Gi" - requests: - cpu: "500m" - memory: "500Mi" - --- apiVersion: v1 kind: Service metadata: name: app-service + labels: + service: app spec: + # type: NodePort + # externalTrafficPolicy: Cluster selector: - app: app + app: app-deployment ports: - - protocol: TCP - port: 8000 - targetPort: 8000 - type: NodePort + - name: http + port: 80 + targetPort: 8000 diff --git a/k8s/prod/app-ingress.yaml b/k8s/prod/app-ingress.yaml new file mode 100644 index 00000000..45442731 --- /dev/null +++ b/k8s/prod/app-ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: app-ingress + annotations: + kubernetes.io/ingress.class: nginx + ## ensure below section is commented out on first creation ## + # cert-manager.io/cluster-issuer: letsencrypt-nginx +spec: + ## ensure below section is comented out on first creation ## + # tls: + # - hosts: + # - api.cursion.dev + # secretName: letsencrypt-nginx + rules: + - host: api.cursion.dev + http: + paths: + - backend: + service: + name: app-service + port: + number: 80 + path: / + pathType: Prefix + ingressClassName: nginx \ No newline at end of file diff --git a/k8s/prod/app-loadbalancer.yaml b/k8s/prod/app-loadbalancer.yaml new file mode 100644 index 00000000..99115af2 --- /dev/null +++ b/k8s/prod/app-loadbalancer.yaml @@ -0,0 +1,688 @@ + +apiVersion: v1 +kind: Namespace +metadata: + name: ingress-nginx + labels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + +--- +# Source: ingress-nginx/templates/controller-serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: controller + name: ingress-nginx + namespace: ingress-nginx +automountServiceAccountToken: true +--- +# Source: ingress-nginx/templates/controller-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: controller + name: ingress-nginx-controller + namespace: ingress-nginx +data: + allow-snippet-annotations: 'true' + use-proxy-protocol: 'true' +--- +# Source: ingress-nginx/templates/clusterrole.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + name: ingress-nginx +rules: + - apiGroups: + - '' + resources: + - configmaps + - endpoints + - nodes + - pods + - secrets + - namespaces + verbs: + - list + - watch + - apiGroups: + - '' + resources: + - nodes + verbs: + - get + - apiGroups: + - '' + resources: + - services + verbs: + - get + - list + - watch + - apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - get + - list + - watch + - apiGroups: + - '' + resources: + - events + verbs: + - create + - patch + - apiGroups: + - networking.k8s.io + resources: + - ingresses/status + verbs: + - update + - apiGroups: + - networking.k8s.io + resources: + - ingressclasses + verbs: + - get + - list + - watch +--- +# Source: ingress-nginx/templates/clusterrolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + name: ingress-nginx +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: ingress-nginx +subjects: + - kind: ServiceAccount + name: ingress-nginx + namespace: ingress-nginx +--- +# Source: ingress-nginx/templates/controller-role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: controller + name: ingress-nginx + namespace: ingress-nginx +rules: + - apiGroups: + - '' + resources: + - namespaces + verbs: + - get + - apiGroups: + - '' + resources: + - configmaps + - pods + - secrets + - endpoints + verbs: + - get + - list + - watch + - apiGroups: + - '' + resources: + - services + verbs: + - get + - list + - watch + - apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - get + - list + - watch + - apiGroups: + - networking.k8s.io + resources: + - ingresses/status + verbs: + - update + - apiGroups: + - networking.k8s.io + resources: + - ingressclasses + verbs: + - get + - list + - watch + - apiGroups: + - '' + resources: + - configmaps + resourceNames: + - ingress-controller-leader + verbs: + - get + - update + - apiGroups: + - '' + resources: + - configmaps + verbs: + - create + - apiGroups: + - '' + resources: + - events + verbs: + - create + - patch +--- +# Source: ingress-nginx/templates/controller-rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: controller + name: ingress-nginx + namespace: ingress-nginx +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ingress-nginx +subjects: + - kind: ServiceAccount + name: ingress-nginx + namespace: ingress-nginx +--- +# Source: ingress-nginx/templates/controller-service-webhook.yaml +apiVersion: v1 +kind: Service +metadata: + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: controller + name: ingress-nginx-controller-admission + namespace: ingress-nginx +spec: + type: ClusterIP + ports: + - name: https-webhook + port: 443 + targetPort: webhook + appProtocol: https + selector: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/component: controller +--- +# Source: ingress-nginx/templates/controller-service.yaml +apiVersion: v1 +kind: Service +metadata: + annotations: + service.beta.kubernetes.io/do-loadbalancer-enable-proxy-protocol: 'true' + service.beta.kubernetes.io/do-loadbalancer-hostname: "api.cursion.dev" + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: controller + name: ingress-nginx-controller + namespace: ingress-nginx +spec: + type: LoadBalancer + externalTrafficPolicy: Cluster + ipFamilyPolicy: SingleStack + ipFamilies: + - IPv4 + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + appProtocol: http + - name: https + port: 443 + protocol: TCP + targetPort: https + appProtocol: https + selector: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/component: controller +--- +# Source: ingress-nginx/templates/controller-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: controller + name: ingress-nginx-controller + namespace: ingress-nginx +spec: + selector: + matchLabels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/component: controller + revisionHistoryLimit: 10 + minReadySeconds: 0 + template: + metadata: + labels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/component: controller + spec: + dnsPolicy: ClusterFirst + containers: + - name: controller + image: k8s.gcr.io/ingress-nginx/controller:v1.1.1@sha256:0bc88eb15f9e7f84e8e56c14fa5735aaa488b840983f87bd79b1054190e660de + imagePullPolicy: IfNotPresent + lifecycle: + preStop: + exec: + command: + - /wait-shutdown + args: + - /nginx-ingress-controller + - --publish-service=$(POD_NAMESPACE)/ingress-nginx-controller + - --election-id=ingress-controller-leader + - --controller-class=k8s.io/ingress-nginx + - --configmap=$(POD_NAMESPACE)/ingress-nginx-controller + - --validating-webhook=:8443 + - --validating-webhook-certificate=/usr/local/certificates/cert + - --validating-webhook-key=/usr/local/certificates/key + securityContext: + capabilities: + drop: + - ALL + add: + - NET_BIND_SERVICE + runAsUser: 101 + allowPrivilegeEscalation: true + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: LD_PRELOAD + value: /usr/local/lib/libmimalloc.so + livenessProbe: + failureThreshold: 5 + httpGet: + path: /healthz + port: 10254 + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 + readinessProbe: + failureThreshold: 3 + httpGet: + path: /healthz + port: 10254 + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 + ports: + - name: http + containerPort: 80 + protocol: TCP + - name: https + containerPort: 443 + protocol: TCP + - name: webhook + containerPort: 8443 + protocol: TCP + volumeMounts: + - name: webhook-cert + mountPath: /usr/local/certificates/ + readOnly: true + resources: + requests: + cpu: 100m + memory: 90Mi + nodeSelector: + kubernetes.io/os: linux + serviceAccountName: ingress-nginx + terminationGracePeriodSeconds: 300 + volumes: + - name: webhook-cert + secret: + secretName: ingress-nginx-admission +--- +# Source: ingress-nginx/templates/controller-ingressclass.yaml +# We don't support namespaced ingressClass yet +# So a ClusterRole and a ClusterRoleBinding is required +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: controller + name: nginx + namespace: ingress-nginx +spec: + controller: k8s.io/ingress-nginx +--- +# Source: ingress-nginx/templates/admission-webhooks/validating-webhook.yaml +# before changing this value, check the required kubernetes version +# https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#prerequisites +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: admission-webhook + name: ingress-nginx-admission +webhooks: + - name: validate.nginx.ingress.kubernetes.io + matchPolicy: Equivalent + rules: + - apiGroups: + - networking.k8s.io + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + resources: + - ingresses + failurePolicy: Fail + sideEffects: None + admissionReviewVersions: + - v1 + clientConfig: + service: + namespace: ingress-nginx + name: ingress-nginx-controller-admission + path: /networking/v1/ingresses + timeoutSeconds: 29 +--- +# Source: ingress-nginx/templates/admission-webhooks/job-patch/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ingress-nginx-admission + namespace: ingress-nginx + annotations: + helm.sh/hook: pre-install,pre-upgrade,post-install,post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: admission-webhook +--- +# Source: ingress-nginx/templates/admission-webhooks/job-patch/clusterrole.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: ingress-nginx-admission + annotations: + helm.sh/hook: pre-install,pre-upgrade,post-install,post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: admission-webhook +rules: + - apiGroups: + - admissionregistration.k8s.io + resources: + - validatingwebhookconfigurations + verbs: + - get + - update +--- +# Source: ingress-nginx/templates/admission-webhooks/job-patch/clusterrolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: ingress-nginx-admission + annotations: + helm.sh/hook: pre-install,pre-upgrade,post-install,post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: admission-webhook +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: ingress-nginx-admission +subjects: + - kind: ServiceAccount + name: ingress-nginx-admission + namespace: ingress-nginx +--- +# Source: ingress-nginx/templates/admission-webhooks/job-patch/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: ingress-nginx-admission + namespace: ingress-nginx + annotations: + helm.sh/hook: pre-install,pre-upgrade,post-install,post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: admission-webhook +rules: + - apiGroups: + - '' + resources: + - secrets + verbs: + - get + - create +--- +# Source: ingress-nginx/templates/admission-webhooks/job-patch/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: ingress-nginx-admission + namespace: ingress-nginx + annotations: + helm.sh/hook: pre-install,pre-upgrade,post-install,post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: admission-webhook +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ingress-nginx-admission +subjects: + - kind: ServiceAccount + name: ingress-nginx-admission + namespace: ingress-nginx +--- +# Source: ingress-nginx/templates/admission-webhooks/job-patch/job-createSecret.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: ingress-nginx-admission-create + namespace: ingress-nginx + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: admission-webhook +spec: + template: + metadata: + name: ingress-nginx-admission-create + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: admission-webhook + spec: + containers: + - name: create + image: k8s.gcr.io/ingress-nginx/kube-webhook-certgen:v1.1.1@sha256:64d8c73dca984af206adf9d6d7e46aa550362b1d7a01f3a0a91b20cc67868660 + imagePullPolicy: IfNotPresent + args: + - create + - --host=ingress-nginx-controller-admission,ingress-nginx-controller-admission.$(POD_NAMESPACE).svc + - --namespace=$(POD_NAMESPACE) + - --secret-name=ingress-nginx-admission + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + securityContext: + allowPrivilegeEscalation: false + restartPolicy: OnFailure + serviceAccountName: ingress-nginx-admission + nodeSelector: + kubernetes.io/os: linux + securityContext: + runAsNonRoot: true + runAsUser: 2000 +--- +# Source: ingress-nginx/templates/admission-webhooks/job-patch/job-patchWebhook.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: ingress-nginx-admission-patch + namespace: ingress-nginx + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: admission-webhook +spec: + template: + metadata: + name: ingress-nginx-admission-patch + labels: + helm.sh/chart: ingress-nginx-4.0.15 + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/version: 1.1.1 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: admission-webhook + spec: + containers: + - name: patch + image: k8s.gcr.io/ingress-nginx/kube-webhook-certgen:v1.1.1@sha256:64d8c73dca984af206adf9d6d7e46aa550362b1d7a01f3a0a91b20cc67868660 + imagePullPolicy: IfNotPresent + args: + - patch + - --webhook-name=ingress-nginx-admission + - --namespace=$(POD_NAMESPACE) + - --patch-mutating=false + - --secret-name=ingress-nginx-admission + - --patch-failure-policy=Fail + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + securityContext: + allowPrivilegeEscalation: false + restartPolicy: OnFailure + serviceAccountName: ingress-nginx-admission + nodeSelector: + kubernetes.io/os: linux + securityContext: + runAsNonRoot: true + runAsUser: 2000 \ No newline at end of file diff --git a/k8s/prod/beat-deployment.yaml b/k8s/prod/beat-deployment.yaml new file mode 100644 index 00000000..6fb83431 --- /dev/null +++ b/k8s/prod/beat-deployment.yaml @@ -0,0 +1,36 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: beat-deployment + labels: + deployment: beat +spec: + replicas: 1 + selector: + matchLabels: + app: beat-deployment + strategy: {} + template: + metadata: + labels: + app: beat-deployment + spec: + imagePullSecrets: + - name: regcred + containers: + - name: beat + image: # cursiondev/server:latest # + imagePullPolicy: Always + command: ["/entrypoint.sh", "beat"] + envFrom: + - configMapRef: + name: app-configs + resources: + limits: + cpu: ".5" + memory: "1Gi" + requests: + cpu: ".5" + memory: "1Gi" + + diff --git a/k8s/prod/celery-autoscaler.yaml b/k8s/prod/celery-autoscaler.yaml new file mode 100644 index 00000000..f8588e37 --- /dev/null +++ b/k8s/prod/celery-autoscaler.yaml @@ -0,0 +1,17 @@ +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: celery-scaler +spec: + scaleTargetRef: + name: celery-deployment + cooldownPeriod: 300 + pollingInterval: 15 + minReplicaCount: 2 + maxReplicaCount: 15 + triggers: + - type: metrics-api + metadata: + targetValue: "5" + url: "https://api.cursion.dev/v1/ops/metrics/celery" + valueLocation: "working_len" \ No newline at end of file diff --git a/k8s/prod/celery-deployment.yaml b/k8s/prod/celery-deployment.yaml index 64ed79e9..79af3f60 100644 --- a/k8s/prod/celery-deployment.yaml +++ b/k8s/prod/celery-deployment.yaml @@ -2,31 +2,47 @@ apiVersion: apps/v1 kind: Deployment metadata: name: celery-deployment + labels: + deployment: celery spec: - replicas: 1 + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 50% + maxUnavailable: 50% selector: matchLabels: - app: celery + app: celery-deployment template: metadata: labels: - app: celery + app: celery-deployment spec: + terminationGracePeriodSeconds: 300 imagePullSecrets: - name: regcred containers: - name: celery - image: landonr/scanerr-server - imagePullPolicy: IfNotPresent - command: ["celery", "-A", "scanerr", "worker", "--beat", "--scheduler", "django", "--loglevel=info"] + image: # cursiondev/server:latest # + imagePullPolicy: Always + command: ["/entrypoint.sh", "celery"] envFrom: - configMapRef: name: app-configs + env: + - name: THIS_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name resources: limits: cpu: "1" - memory: "1Gi" + memory: "3.5Gi" requests: - cpu: "500m" - memory: "500Mi" - + cpu: "1" + memory: "2Gi" + lifecycle: + preStop: + exec: + command: ["python3", "manage.py", "terminate_worker"] \ No newline at end of file diff --git a/k8s/prod/kubeip-daemon.yaml b/k8s/prod/kubeip-daemon.yaml new file mode 100644 index 00000000..c978f8b6 --- /dev/null +++ b/k8s/prod/kubeip-daemon.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: kubeip +spec: + selector: + matchLabels: + app: kubeip + template: + metadata: + labels: + app: kubeip + spec: + serviceAccountName: kubeip-service-account + terminationGracePeriodSeconds: 30 + priorityClassName: system-node-critical + nodeSelector: + kubeip.com/public: "true" + containers: + - name: kubeip + image: doitintl/kubeip-agent + resources: + requests: + cpu: 100m + env: + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + # - name: FILTER + # value: PUT_PLATFORM_SPECIFIC_FILTER_HERE + - name: LOG_LEVEL + value: debug + - name: LOG_JSON + value: "true" \ No newline at end of file diff --git a/k8s/prod/kubeip-service.yaml b/k8s/prod/kubeip-service.yaml new file mode 100644 index 00000000..590b9312 --- /dev/null +++ b/k8s/prod/kubeip-service.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kubeip-service-account + namespace: kube-system +--- + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kubeip-cluster-role +rules: + - apiGroups: [ "" ] + resources: [ "nodes" ] + verbs: [ "get" ] + - apiGroups: [ "coordination.k8s.io" ] + resources: [ "leases" ] + verbs: [ "create", "get", "delete" ] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kubeip-cluster-role-binding +subjects: + - kind: ServiceAccount + name: kubeip-service-account + namespace: kube-system +roleRef: + kind: ClusterRole + name: kubeip-cluster-role + apiGroup: rbac.authorization.k8s.io \ No newline at end of file diff --git a/k8s/prod/old_configs/app-pvc.yaml b/k8s/prod/old_configs/app-pvc.yaml new file mode 100644 index 00000000..baa2b84c --- /dev/null +++ b/k8s/prod/old_configs/app-pvc.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: app-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi \ No newline at end of file diff --git a/k8s/prod/old_configs/celery-autoscaler.yaml b/k8s/prod/old_configs/celery-autoscaler.yaml new file mode 100644 index 00000000..a7e31e77 --- /dev/null +++ b/k8s/prod/old_configs/celery-autoscaler.yaml @@ -0,0 +1,43 @@ +# apiVersion: keda.sh/v1alpha1 +# kind: ScaledObject +# metadata: +# name: celery-scaler +# spec: +# scaleTargetRef: +# name: celery-deployment +# pollingInterval: 3 +# minReplicaCount: 2 +# maxReplicaCount: 15 +# triggers: +# - type: redis +# metadata: +# address: redis.default.svc.cluster.local:6379 # Format must be host:port redis:6379 +# listName: celery # Required +# listLength: "5" # Required +# activationListLength: "5" # optional +# enableTLS: "false" # optional +# unsafeSsl: "false" # optional +# databaseIndex: "0" # optional + + + + +# apiVersion: keda.sh/v1alpha1 +# kind: ScaledObject +# metadata: +# name: celery-scaler +# spec: +# scaleTargetRef: +# name: celery-deployment +# cooldownPeriod: 4000 +# pollingInterval: 3 +# minReplicaCount: 2 +# maxReplicaCount: 15 +# triggers: +# - type: rabbitmq +# metadata: +# host: amqp://rabbitmq.default.svc.cluster.local:5672 # rabbitmq.default.svc.cluster.local:5672 Optional. If not specified, it must be done by using TriggerAuthentication. +# mode: QueueLength # QueueLength or MessageRate +# value: "5" # message backlog or publish/sec. target per instance +# activationValue: "5" # Optional. Activation threshold +# queueName: celery \ No newline at end of file diff --git a/k8s/prod/old_configs/rabbitmq-deployment.yaml b/k8s/prod/old_configs/rabbitmq-deployment.yaml new file mode 100644 index 00000000..18ada4c6 --- /dev/null +++ b/k8s/prod/old_configs/rabbitmq-deployment.yaml @@ -0,0 +1,49 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + service: rabbitmq + name: rabbitmq +spec: + replicas: 1 + selector: + matchLabels: + service: rabbitmq + strategy: {} + template: + metadata: + labels: + service: rabbitmq + spec: + restartPolicy: Always + containers: + - image: rabbitmq:alpine + name: rabbitmq + ports: + - containerPort: 5672 + resources: + limits: + cpu: "250m" + memory: "250Mi" + requests: + cpu: "100m" + memory: "100Mi" +status: {} + + + +--- + +apiVersion: v1 +kind: Service +metadata: + labels: + service: rabbitmq + name: rabbitmq +spec: + ports: + - name: "5672" + port: 5672 + targetPort: 5672 + selector: + service: rabbitmq diff --git a/k8s/prod/ylt-autoscaler.yaml b/k8s/prod/ylt-autoscaler.yaml new file mode 100644 index 00000000..e7490c5c --- /dev/null +++ b/k8s/prod/ylt-autoscaler.yaml @@ -0,0 +1,17 @@ +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: ylt-scaler +spec: + scaleTargetRef: + name: ylt-deployment + cooldownPeriod: 300 + pollingInterval: 15 + minReplicaCount: 2 + maxReplicaCount: 7 + triggers: + - type: metrics-api + metadata: + targetValue: "10" + url: "https://api.cursion.dev/v1/ops/metrics/celery" + valueLocation: "working_len" \ No newline at end of file diff --git a/k8s/prod/ylt-deployment.yaml b/k8s/prod/ylt-deployment.yaml new file mode 100644 index 00000000..b2899d8f --- /dev/null +++ b/k8s/prod/ylt-deployment.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ylt-deployment + labels: + deployment: yellowlab +spec: + replicas: 2 + selector: + matchLabels: + app: ylt-deployment + template: + metadata: + labels: + app: ylt-deployment + spec: + terminationGracePeriodSeconds: 30 + containers: + - name: yellowlab + image: cursiondev/ylt + env: + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + ports: + - containerPort: 8383 + securityContext: + privileged: true + resources: + limits: + cpu: "1" + memory: "4Gi" + requests: + cpu: ".5" + memory: "1Gi" +--- + +apiVersion: v1 +kind: Service +metadata: + name: ylt-service + labels: + service: ylt +spec: + selector: + app: ylt-deployment + ports: + - name: http + port: 80 + targetPort: 8383 \ No newline at end of file diff --git a/nginx/README.md b/nginx/README.md new file mode 100644 index 00000000..60b38868 --- /dev/null +++ b/nginx/README.md @@ -0,0 +1,4 @@ +## Build & Push Instructions +1. Ensure you are in the root of `nginx` +2. Build Dockerfile `docker build --platform linux/amd64 . -t 'cursiondev/nginx:latest'` +3. Push to dock Dockerfile `docker push cursiondev/nginx:latest` \ No newline at end of file diff --git a/nginx/vhost.d/default b/nginx/vhost.d/default index c498447b..fe37f6ef 100644 --- a/nginx/vhost.d/default +++ b/nginx/vhost.d/default @@ -1,9 +1,8 @@ -location /static/ { - alias /app/static/; +location /staticfiles/ { + alias /app/staticfiles/; add_header Access-Control-Allow-Origin *; } - diff --git a/notes/Deployment.md b/notes/Deployment.md new file mode 100644 index 00000000..1b564fcb --- /dev/null +++ b/notes/Deployment.md @@ -0,0 +1,168 @@ +# Cursion Deployment (single Server) + +- [Cursion Deployment (single Server)](#cursion-deployment-single-server) + - [Environment](#environment) + - [Local](#local) + - [Remote](#remote) + - [Scripts](#scripts) + - [Install and run Docker in Containers](#install-and-run-docker-in-containers) + - [Get \& Set Node Memory:](#get--set-node-memory) + - [Clean up Docker leftovers on Server](#clean-up-docker-leftovers-on-server) + + +  + +--- +  + +## Environment + +Prior to running app, configure all env's located in the /env directory. There are example .env files for both production and local environments marked `.env.dev.example` and `.env.prod.example`. Prior to running the app, be sure to update with your unique keys, domains, passwords, etc, and remove the `.example` extention from the files. **Never store actual .env's in a repo.** Things to change: +- high level django configs +- admin credentials +- email credentials +- database configs +- google API keys +- stripe keys +- OAuth keys +- twilio credentials +- slack credentials +- s3 remote storage credentials + +  + +--- +  + +## Local +Install and run locally on your machine in a dev environment. + +> Ensure you have Docker and Docker-desktop installed and running on your machine prior to this step. + +```shell +$ pip3 install virtualenv +$ virtualenv appenv +$ source appenv/bin/activate +$ mkdir app +$ git clone https://github.com/Cursion/server.git +``` +*Spin-up the application* +```shell +$ docker compose -f docker-compose.local.yml up --build +``` +*Spin-down the application* +```shell +$ docker compose -f docker-compose.local.yml down +``` + +  + +--- +  + +## Remote +Install and deploy remotely in a production environment. + +> Ensure you have Docker installed and running on your server prior to this step. + +*Server configurations for Ubuntu 20.04* +``` shell +$ ssh root@your_server_ip +# apt update +# apt upgrade +# adduser {user} +# usermod -aG sudo {user} +# ufw allow OpenSSH +# ufw enable +# su {user} +``` + +*Add user to docker group* +```shell +$ sudo usermod -aG docker {user} +$ newgrp docker +``` + +*Generate SSH keys for GitHub* +``` shell +$ ssh-keygen -t ed25519 -C "your_github_email@example.com" +``` +- press `Enter` 3 times +```shell +$ eval "$(ssh-agent -s)" +$ ssh-add ~/.ssh/id_ed25519 +$ cat ~/.ssh/id_ed25519.pub +``` +- copy key to clipboard and paste in GitHub + + +*Add ssh_key.pub to {user} authorized_keys* +```shell +$ {your_ssh_key.pub} >> ~/.ssh/authorized_keys +``` + + +*Create a dir to clone the app into* +``` shell +$ cd ~ +$ mkdir app +$ cd app +$ git clone git@github.com:Cursion/server.git +``` +*Spin-up the application* +```shell +$ docker compose -f docker-compose.prod.yml up -d --build +``` +*Spin-down the application* +```shell +$ docker compose -f docker-compose.prod.yml down +``` +*Spin-down the application and removes the volumes* +```shell +$ docker-compose -f docker-compose.prod.yml down -v +``` + + + +  + +--- + +  + +## Scripts + +*ssh into container* +``` shell +$ docker exec -it /bin/sh +``` + + +### Install and run Docker in Containers +```shell +sed -i 's/ulimit -Hn/# ulimit -Hn/g' /etc/init.d/docker; +service docker start && +sleep 10 && +docker run -d --privileged --restart unless-stopped -p 8383:8383 cursion/ylt && +``` + + +### Get & Set Node Memory: +Get Current Memory +```shell +node -e 'console.log(`Node RAM space: ${v8.getHeapStatistics().heap_size_limit/(1024*1024)}`)' +``` +Set New Memory +```shell +export NODE_OPTIONS="--max-old-space-size=4080" # Increase to 4 GB +export NODE_OPTIONS="--max-old-space-size=5120" # Increase to 5 GB +export NODE_OPTIONS="--max-old-space-size=6144" # Increase to 6 GB +export NODE_OPTIONS="--max-old-space-size=7168" # Increase to 7 GB +export NODE_OPTIONS="--max-old-space-size=8192" # Increase to 8 GB +``` + + +### Clean up Docker leftovers on Server +```shell +docker system prune --all --force --volumes +``` \ No newline at end of file diff --git a/notes/Kubernetes.md b/notes/Kubernetes.md new file mode 100644 index 00000000..9ad6d2fb --- /dev/null +++ b/notes/Kubernetes.md @@ -0,0 +1,221 @@ +# Notes on k8s deployments +--- +
+ + +### Create k8s files in yaml (kompose) +```shell +kompose convert -f docker-compose.yml -o ./k8s +``` + +### Build k8s +```shell +kubectl apply -f ./k8s/k8s-local.yaml +``` + +### Delete k8s +```shell +kubectl delete -f ./k8s/k8s-local.yaml +``` + +### List containers +```shell +kubectl get pod +``` + +### List pods with IPs +```shell +kubectl get pod -o wide +``` + +### To get all creation events for debugging: +```shell +kubectl get events --sort-by=.metadata.creationTimestamp +``` + +### SSH into container: +```shell +kubectl exec -it celery-849f76858b-bvmqg -- /bin/sh +``` + +### Creating secrets for docker: +```shell +kubectl create secret docker-registry regcred --docker-server=https://index.docker.io/v1/ --docker-username=landonr --docker-password= --docker-email= +``` + +#### - Then add this to both celery and app containers: +```yaml +spec: + imagePullSecrets: + - name: regcred +``` + +### Start and Stop minikube +```shell +minikube start +minikube stop +``` + +### Port Forwarding for app +```shell +kubectl port-forward service/app-service 8000:8000 +``` + + +
+ + +# Setps to Deploy localy +1. ensure minikube is running + - ``` minikube status ``` +2. create secrets for app image pull from docker + - ``` kubectl create secret docker-registry regcred --docker-server=https://index.docker.io/v1/ --docker-username= --docker-password= --docker-email= ``` +3. build db-configs-configs + - ``` kubectl apply db-configs.yaml ``` +4. build db-deployment + - ``` kubectl apply db-deployment.yaml ``` +5. build redis-deployment +6. get pod ip of db-deployment + - ``` kubectl get pod --template '{{.status.podIP}}' ``` + - or ``` kubectl get pod -o wide ``` +7. copy ip and paste into app-configs-configs for field "DB_HOST" +8. build app-configs + - ``` kubectl apply app-config.yaml ``` +9. build app-deployment + - ``` kubectl apply db-deployment.yaml ``` +10. build celery-deployment + - ``` kubectl apply db-deployment.yaml ``` +11. port forwarding to app deployment + - ``` kubectl port-forward service/app-service 8000:8000 ``` + + +--- + +
+ +# Setps to Deploy Remotely + +> Ensure you are in the `/server` root directory + + + +### 0. Create K8s cluster and Configure context +``` shell +doctl kubernetes cluster kubeconfig save {{cluster-name}} +``` + + +### 1. Create docker secrets +``` shell +kubectl create secret docker-registry regcred --docker-server=https://index.docker.io/v1/ --docker-username='' --docker-password='' --docker-email='' +``` + + +### 1. Build Dockerfile into image +``` shell +docker build . -t cursiondev/server:latest --platform linux/amd64 +docker image push cursiondev/server:latest +``` + + +### 2. Install nginx ingress controler on cluster +``` shell +kubectl apply -f ./k8s/prod/app-loadbalancer.yaml +``` +- Then add and `A` record for domain that points to new loadbalancer + - ref -> https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.1.1/deploy/static/provider/do/deploy.yaml + + +### 3. Update ingress-nginx-controler "Service file" with domain - if not already updated. +- add the below annotation +``` shell +service.beta.kubernetes.io/do-loadbalancer-hostname: "api.cursion.dev" +``` + + +### 4. Spin up Cursion deployments and services +``` shell +kubectl apply -f ./k8s/prod/app-configs.yaml +kubectl apply -f ./k8s/prod/redis-deployment.yaml +kubectl apply --server-side -f https://github.com/kedacore/keda/releases/download/v2.11.0/keda-2.11.0.yaml +kubectl apply -f ./k8s/prod/app-deployment.yaml +kubectl apply -f ./k8s/prod/celery-deployment.yaml +kubectl apply -f ./k8s/prod/celery-autoscaler.yaml +kubectl apply -f ./k8s/prod/beat-deployment.yaml +``` + + +#### 4.a Spin up YLT deploymemt, service, and autoscaler +``` shell +kubectl apply -f ./k8s/prod/ylt-deployment.yaml +kubectl apply -f ./k8s/prod/ylt-autoscaler.yaml # DEPRECIATE +``` + + +### 5. Add app Ingress +``` shell +kubectl apply -f ./k8s/prod/app-ingress.yaml +``` + + +### 6. Install cert-manager +``` shell +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.12.0/cert-manager.yaml +``` + + +### 7. Add cert issure +``` shell +kubectl apply -f ./k8s/prod/app-cert-issuer.yaml +``` +> NOTE: May have to wait a bit before running this one + + +### 8. Update app Ingress for TLS +- Uncomment the "TLS section" & "cert-manager.io/cluster-issuer annotation" then reapply +``` shell +kubectl apply -f ./k8s/prod/app-ingress.yaml +``` + + +### 9. Install kubeip dameon & service +``` shell +kubectl apply -f ./k8s/prod/kubeip-service.yaml +kubectl apply -f ./k8s/prod/kubeip-daemon.yaml +``` + + +### NOTES: + - When reprovisioning to new domains and SSL certs ensure all `certificates` & `secrets` are deleted + - `kubectl delete certificate ` + - `kubectl delete secret ` ... may have to do this in the k8s dashboard + - Restart celery, beat, & app deployments for a config-map change: + - `kubectl rollout restart deployment app-deployment celery-deployment beat-deployment` + - Check status of deployment rollout + - `kubectl rollout status deployment/app-deployment` + - Get Current IPs for pods: + - `kubectl exec -- curl -s http://checkip.dyndns.org/ | sed 's/[a-zA-Z<>/ :]//g'` + - Force delete pods that are stuck in `Terminating`: + - `for p in $(kubectl get pods | grep Terminating | awk '{print $1}'); do kubectl delete pod $p --grace-period=0 --force;done` + - Stream Logs for all celery-deployments: + - `kubectl logs -f --all-containers deployment/celery-deployment` + - `kubectl logs -f --selector=app=celery-deployment --all-containers --max-log-requests=7` + + + +--- + +
+ +# Migration Notes for DB: +1. Go to `models.py` and comment out all new additions +2. Spinup staging env locally to create `00001_initial.py` migration as baseline + - `docker compose -f docker-compose.stage.yml up --build` +3. Spin down staging env + - `docker compose -f docker-compose.stage.yml down` +4. Un-comment all new additions in `models.py` +5. Spinup staging env locally again and ensure a new migration file is created in `/migrations` + - `docker compose -f docker-compose.stage.yml up --build` +6. Spin down staging env + - `docker compose -f docker-compose.stage.yml down` +7. Merge `dev` branch on github using a pull request \ No newline at end of file diff --git a/setup/requirements/requirements.txt b/setup/requirements/requirements.txt new file mode 100644 index 00000000..0bb2d94e --- /dev/null +++ b/setup/requirements/requirements.txt @@ -0,0 +1,59 @@ +amqp==5.2.0 +asgiref==3.8.1 +beautifulsoup4==4.12.2 +billiard==4.2.0 +boto3==1.20.32 +celery==5.4.0 +certifi==2023.7.22 +chardet==4.0.0 +click==8.1.7 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +cryptography==43.0.3 +Django==5.0.6 +django-celery-beat==2.7.0 +django-filter==24.2 +djangorestframework==3.15.1 +django-markdownify==0.9.5 +django-cors-headers==4.3.1 +django-storages==1.14.3 +djangorestframework-simplejwt==5.3.1 +docker==5.0.0 +gunicorn==20.1.0 +humanize==3.7.0 +idna==2.10 +imutils==0.5.4 +kombu==5.3.7 +Markdown==3.6 +numpy~=1.26.4 +openai==1.57.0 +opencv-python==4.5.5.64 +Pillow==10.3.0 +prometheus-client==0.8.0 +prompt-toolkit==3.0.43 +psycopg2==2.9.9 +pyjwt==2.1.0 +pytz==2021.1 +redis==3.5.3 +requests==2.25.1 +reportlab==4.2.0 +scikit-image==0.23.2 +scipy==1.13.0 +selenium==4.18.1 +sendgrid==6.9.7 +six==1.16.0 +slack-sdk==3.11.2 +sqlparse==0.4.1 +stripe==8.0.0 +tornado==6.1 +twilio==7.3.0 +urllib3==1.26.5 +vine==5.1.0 +wcwidth==0.2.5 +websocket-client==1.0.1 +whitenoise==6.1.0 + + + + diff --git a/setup/scripts/entrypoint.sh b/setup/scripts/entrypoint.sh new file mode 100755 index 00000000..fb001213 --- /dev/null +++ b/setup/scripts/entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# spin up server in local, remote, or stage env +if [[ $1 == *"server"* ]] + then + if [[ $2 == *"local"* ]] + then + python3 manage.py wait_for_db && + python3 manage.py migrate --no-input && + python3 manage.py create_admin && + python3 manage.py verify_account && + python3 manage.py create_tasks && + python3 manage.py test_driver && + python3 manage.py runserver 0.0.0.0:8000 + fi + if [[ $2 == *"remote"* ]] + then + python3 manage.py wait_for_db && + python3 manage.py migrate --no-input && + python3 manage.py create_admin && + python3 manage.py verify_account && + python3 manage.py create_tasks && + python3 manage.py test_driver && + gunicorn --timeout 1000 --graceful-timeout 1000 --keep-alive 3 --log-level debug cursion.wsgi:application --bind 0.0.0.0:8000 + fi + if [[ $2 == *"stage"* ]] + then + python3 manage.py wait_for_db && + python3 manage.py makemigrations --no-input && + python3 manage.py migrate --no-input + fi +fi + +# spin up celery +if [[ $1 == *"celery"* ]] + then + python3 manage.py wait_for_db && + echo "pausing for migrations to complete..." && sleep 7s && + celery -A cursion worker -E --loglevel=info -O fair +fi + +# spin up celery beat +if [[ $1 == *"beat"* ]] + then + python3 manage.py wait_for_db && + echo "pausing for migrations to complete..." && sleep 7s && + celery -A cursion beat --scheduler django --loglevel=info +fi + diff --git a/setup/scripts/local.sh b/setup/scripts/local.sh new file mode 100644 index 00000000..eb614a84 --- /dev/null +++ b/setup/scripts/local.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# ensure you create $CURSION_ROOT first: +# " echo 'export CURSION_ROOT=' >> ~/.zshrc (or ~/.bash_profile) " + +cd $CURSION_ROOT/server && +{ + docker compose -f docker-compose.yml down && + docker volume rm server_server server_beat server_celery && + docker compose -f docker-compose.yml up --build +} || { + docker volume rm server_server server_beat server_celery && + docker compose -f docker-compose.yml up --build +} || { + docker compose -f docker-compose.yml up --build +} + + + +# cmd to run +# > source ./setup/scripts/local.sh \ No newline at end of file