├── .env.example ├── .github └── workflows │ ├── build.yml │ ├── production.yml │ ├── staging.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── alerts ├── __init__.py ├── admin.py ├── apps.py ├── management │ └── commands │ │ └── run_cron_alerts.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py └── views.py ├── apimonitor ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_apimonitorresult_log_error_and_more.py │ ├── 0003_apimonitorresult_status_code.py │ ├── 0004_remove_apimonitorresult_date_and_more.py │ ├── 0005_apimonitor_assertion_value_apimonitor_assetion_type_and_more.py │ ├── 0006_apimonitor_is_assert_json_schema_only_and_more.py │ ├── 0007_rename_assetion_type_apimonitor_assertion_type.py │ ├── 0008_alter_assertionexcludekey_monitor.py │ ├── 0009_alertsconfiguration_email_use_ssl_and_more.py │ ├── 0010_alertsconfiguration_threshold_pct_and_more.py │ ├── 0011_alertsconfiguration_pagerduty_service_id_and_more.py │ ├── 0012_alertsconfiguration_email_address_and_more.py │ ├── 0012_rename_discord_guild_id_alertsconfiguration_discord_webhook_url_and_more.py │ ├── 0013_merge_20221106_1259.py │ ├── 0014_alertsconfiguration_utc.py │ ├── 0014_remove_alertsconfiguration_user_and_more.py │ ├── 0015_alter_alertsconfiguration_team_alter_apimonitor_team.py │ ├── 0016_merge_20221116_1054.py │ ├── 0017_apimonitor_status_page_category.py │ ├── 0018_alter_apimonitor_assertion_type.py │ └── __init__.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py └── views.py ├── apitest ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py └── views.py ├── cron ├── __init__.py ├── admin.py ├── apps.py ├── management │ └── commands │ │ └── run_cron.py ├── migrations │ └── __init__.py ├── models.py └── tests.py ├── docs ├── development.md └── release_notes.md ├── error_logs ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py └── views.py ├── forget_password ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py └── views.py ├── invite_team_members ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_remove_inviteteammembertoken_team_and_more.py │ └── __init__.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py └── views.py ├── login ├── __init__.py ├── admin.py ├── apps.py ├── auth.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_team_description_alter_team_logo.py │ ├── 0003_teammember_verified.py │ └── __init__.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py ├── utils.py └── views.py ├── manage.py ├── monapi ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py ├── utils.py └── wsgi.py ├── password_validators ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── tests.py └── validators.py ├── pull_request_template.md ├── register ├── __init__.py ├── admin.py ├── api │ └── tests.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py └── views.py ├── requirements.txt ├── sonar-project.properties ├── statuspage ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_statuspageconfiguration_path.py │ ├── 0003_alter_statuspageconfiguration_path.py │ └── __init__.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py └── views.py ├── team_management ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py └── views.py └── template └── email ├── accept_invite.html ├── alerts.html ├── reset_password.html ├── style.css └── verify_email.html /.env.example: -------------------------------------------------------------------------------- 1 | PRODUCTION=False 2 | ALLOWED_HOSTS=* 3 | CSRF_TRUSTED_ORIGINS=http://* 4 | DB_NAME= 5 | DB_USER= 6 | DB_PASSWORD= 7 | DB_HOST= 8 | DB_PORT= 9 | STATIC_ROOT= 10 | MEDIA_ROOT= 11 | FRONTEND_URL=http://localhost:8080 12 | CORS_ALLOWED_ORIGINS=http://localhost:8080 13 | CRON_THREAD_COUNT=3 14 | CRON_ALERTS_THREAD_COUNT=1 15 | CRON_INTERVAL_IN_SECONDS=60 16 | EMAIL_HOST= 17 | EMAIL_PORT=25 18 | EMAIL_HOST_USER= 19 | EMAIL_HOST_PASSWORD= 20 | EMAIL_USE_TLS=False 21 | EMAIL_USE_SSL=False 22 | DEFAULT_FROM_EMAIL= 23 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - staging 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | jobs: 10 | sonarcloud: 11 | name: SonarCloud 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 17 | - name: Setup Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.8' 21 | - name: Run test and coverage report 22 | run: | 23 | pip install -r requirements.txt 24 | coverage run --omit="**/tests.py,monapi/settings.py,manage.py" manage.py test 25 | coverage xml 26 | - name: Override Coverage Source Path for Sonar 27 | run: sed -i "s/\/home\/runner\/work\/MonAPI\/MonAPI<\/source>/\/github\/workspace<\/source>/g" /home/runner/work/MonAPI/MonAPI/coverage.xml 28 | - name: SonarCloud Scan 29 | uses: SonarSource/sonarcloud-github-action@master 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 32 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/production.yml: -------------------------------------------------------------------------------- 1 | name: Production Deployment 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | test: 8 | runs-on: ubuntu-20.04 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-python@v4 12 | with: 13 | python-version: '3.8' 14 | - run: | 15 | pip install -r requirements.txt 16 | python manage.py test 17 | deployment: 18 | needs: test 19 | runs-on: ubuntu-20.04 20 | environment: production 21 | steps: 22 | - name: Deploy to production server 23 | run: | 24 | command -v ssh-agent >/dev/null 25 | apt-get update -y && apt-get install openssh-client -y 26 | eval `ssh-agent -s` && echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add - 27 | mkdir -p ~/.ssh 28 | chmod 700 ~/.ssh 29 | ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts 30 | ssh root@${{ secrets.SERVER_IP }} "set -x && ./deploy_backend_production.sh && exit" 31 | -------------------------------------------------------------------------------- /.github/workflows/staging.yml: -------------------------------------------------------------------------------- 1 | name: Staging Deployment 2 | on: 3 | push: 4 | branches: 5 | - staging 6 | jobs: 7 | test: 8 | runs-on: ubuntu-20.04 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-python@v4 12 | with: 13 | python-version: '3.8' 14 | - run: | 15 | pip install -r requirements.txt 16 | python manage.py test 17 | deployment: 18 | needs: test 19 | runs-on: ubuntu-20.04 20 | environment: staging 21 | steps: 22 | - name: Deploy to staging server 23 | run: | 24 | command -v ssh-agent >/dev/null 25 | apt-get update -y && apt-get install openssh-client -y 26 | eval `ssh-agent -s` && echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add - 27 | mkdir -p ~/.ssh 28 | chmod 700 ~/.ssh 29 | ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts 30 | ssh root@${{ secrets.SERVER_IP }} "set -x && ./deploy_backend_staging.sh && exit" 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | jobs: 9 | test: 10 | name: Test 11 | runs-on: ubuntu-20.04 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Setup Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.8' 18 | - name: Run test and coverage report 19 | run: | 20 | pip install -r requirements.txt 21 | python manage.py test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | monapi/static/ 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | test.db.sqlite3 65 | test.db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # Upload folder 135 | uploads/** 136 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | python manage.py test 3 | 4 | test-cover: 5 | coverage run --omit="**/tests.py,monapi/settings.py,manage.py" manage.py test 6 | coverage report -m 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MonAPI 2 | ![MonAPI Logo](https://blog.monapi.xyz/wp-content/uploads/2022/09/Group-19.png) 3 | 4 | MonAPI: Democratizing API Monitoring Tools through Open Sourcing 5 | 6 | This repository is providing backend service for MonAPI, written in Python and using Django Rest Framework. 7 | 8 | 9 | | Pipeline | Status | 10 | | ----------- | ----------- | 11 | | Sonarcloud Scanner | [![pipeline status](https://github.com/MonAPI-xyz/MonAPI/actions/workflows/build.yml/badge.svg)](https://github.com/MonAPI-xyz/MonAPI) | 12 | | Quality Gate Production | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=MonAPI-xyz_MonAPI&metric=alert_status&branch=main)](https://sonarcloud.io/summary/new_code?id=MonAPI-xyz_MonAPI) | 13 | | Security Rating Production| [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=MonAPI-xyz_MonAPI&metric=security_rating&branch=main)](https://sonarcloud.io/summary/new_code?id=MonAPI-xyz_MonAPI) | 14 | | Coverage Production| [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=MonAPI-xyz_MonAPI&metric=coverage&branch=main)](https://sonarcloud.io/summary/new_code?id=MonAPI-xyz_MonAPI) | 15 | | Quality Gate Staging| [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=MonAPI-xyz_MonAPI&metric=alert_status&branch=staging)](https://sonarcloud.io/summary/new_code?id=MonAPI-xyz_MonAPI) | 16 | | Security Rating Staging| [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=MonAPI-xyz_MonAPI&metric=security_rating&branch=staging)](https://sonarcloud.io/summary/new_code?id=MonAPI-xyz_MonAPI) | 17 | | Coverage Staging| [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=MonAPI-xyz_MonAPI&metric=coverage&branch=staging)](https://sonarcloud.io/summary/new_code?id=MonAPI-xyz_MonAPI) | 18 | | Production Deploy | [![pipeline status](https://github.com/MonAPI-xyz/MonAPI/actions/workflows/production.yml/badge.svg)](https://github.com/MonAPI-xyz/MonAPI) | 19 | | Staging Deploy | [![pipeline status](https://github.com/MonAPI-xyz/MonAPI/actions/workflows/staging.yml/badge.svg)](https://github.com/MonAPI-xyz/MonAPI) | 20 | 21 | ## Table of contents 22 | - [Features](#features) 23 | - [Related Documentation](#related-documentation) 24 | - [Latest Release Notes](#latest-release-notes) 25 | - [Our Websites](#our-websites) 26 | - [Our Teams](#our-teams) 27 | - [Licence](#license) 28 | - [Acknowledgements](#acknowledgements) 29 | 30 | ## Features 31 | 32 | - [x] Authentication 33 | - [x] API Monitor Dashboard 34 | - [x] API Monitor Alert 35 | - [x] API Monitor Error Log 36 | - [x] API Test 37 | - [x] Multi-Step API Monitor 38 | - [x] Team Management 39 | - [x] Integrated Status Page 40 | 41 | ## Related Documentation 42 | 43 | - [Run Development Server](https://github.com/MonAPI-xyz/MonAPI/blob/staging/docs/development.md) 44 | 45 | ## Latest Release Notes 46 | Version: v1.0.1
47 | Date: 28th December 2022 48 | 1. Fix bug password validation on forget password 49 | 2. Update pagerduty email configuration title 50 | 51 | 52 | Full release notes can be found in [Release Notes](https://github.com/MonAPI-xyz/MonAPI/blob/staging/docs/release_notes.md) 53 | 54 | ## Our websites 55 | 🌐 [Main Site - https://monapi.xyz](https://monapi.xyz) 56 | 57 | 📝 [Blog Site - https://blog.monapi.xyz](https://blog.monapi.xyz) 58 | 59 | 📝 [User Manual - https://docs.monapi.xyz](https://docs.monapi.xyz) 60 | 61 | 📝 [Technical Documentation - https://docs.monapi.xyz/monapi-tech-documentation/](https://docs.monapi.xyz/monapi-tech-documentation/) 62 | 63 | 📺 [Youtube - https://www.youtube.com/@monapi](https://www.youtube.com/@monapi) 64 | 65 | 66 | ## Our Teams 67 | - Lucky Susanto 68 | - Ferdi Fadillah 69 | - Hugo Irwanto 70 | - Muhammad Luthfi Fahlevi 71 | - Andrew 72 | 73 | ## License 74 | The scripts and documentation in this project are released under the [GNU General Public License v3.0](https://github.com/MonAPI-xyz/MonAPI/blob/main/LICENSE). 75 | 76 | ## Acknowledgements 77 | * Computer Science Universitas Indonesia - Software Engineering Project 2022 78 | 79 | [![SonarCloud](https://sonarcloud.io/images/project_badges/sonarcloud-white.svg)](https://sonarcloud.io/summary/new_code?id=MonAPI-xyz_MonAPI) 80 | -------------------------------------------------------------------------------- /alerts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/alerts/__init__.py -------------------------------------------------------------------------------- /alerts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /alerts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AlertsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'alerts' 7 | -------------------------------------------------------------------------------- /alerts/management/commands/run_cron_alerts.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import time 4 | import threading 5 | import queue 6 | from datetime import timedelta, timezone as tzdt 7 | 8 | from django.core.mail import get_connection 9 | from django.template.loader import render_to_string 10 | from django.utils import timezone 11 | from django.db.models import Count, Q 12 | from django.core.management.base import BaseCommand 13 | 14 | from apimonitor.models import APIMonitor, APIMonitorResult, AlertsConfiguration 15 | from login.models import TeamMember 16 | 17 | from discord_webhook import DiscordWebhook, DiscordEmbed 18 | # Mock this function to interrupt the cron function 19 | def mock_cron_interrupt(): 20 | pass 21 | 22 | 23 | class Command(BaseCommand): 24 | help = 'Run Cron Alerts' 25 | 26 | queue = {} 27 | channels = ['slack', 'discord', 'pagerduty', 'email'] 28 | 29 | stop_signal = threading.Event() 30 | 31 | def get_success_rate(self, monitor): 32 | time_window_in_seconds = { 33 | '1H': 3600, 34 | '2H': 7200, 35 | '3H': 10800, 36 | '6H': 21600, 37 | '12H': 43200, 38 | '24H': 86400, 39 | } 40 | 41 | alerts_config, _ = AlertsConfiguration.objects.get_or_create(team=monitor.team) 42 | end_time = timezone.now() 43 | start_time = end_time - timedelta(seconds=time_window_in_seconds[alerts_config.time_window]) 44 | 45 | # Average success rate 46 | success_count = APIMonitorResult.objects \ 47 | .filter(monitor=monitor, execution_time__gte=start_time) \ 48 | .aggregate( 49 | s=Count('success', filter=Q(success=True)), 50 | f=Count('success', filter=Q(success=False)), 51 | total=Count('pk'), 52 | ) 53 | 54 | success_rate = 100 55 | if success_count['total'] != 0: 56 | success_rate = success_count['s'] / (success_count['total']) * 100 57 | 58 | formatted_success_rate = round(float(success_rate), 2) 59 | formatted_start_time = start_time.astimezone(tzdt(timedelta(hours=+alerts_config.utc))).strftime("%d %b %Y, %H:%M:%S %Z") 60 | formatted_end_time = end_time.astimezone(tzdt(timedelta(hours=+alerts_config.utc))).strftime("%d %b %Y, %H:%M:%S %Z") 61 | 62 | return formatted_success_rate , formatted_start_time, formatted_end_time 63 | 64 | def get_monitor_id_from_queue(self, type): 65 | while True: 66 | try: 67 | q = self.queue[type] 68 | monitor_id = q.get(block=False) 69 | return monitor_id 70 | except queue.Empty: 71 | if self.stop_signal.is_set(): 72 | return None 73 | time.sleep(0.2) 74 | 75 | def put_monitor_id_into_queue(self, monitor_id): 76 | for channel in self.channels: 77 | q = self.queue[channel] 78 | q.put(monitor_id) 79 | 80 | def send_alert_slack(self, monitor_id, success_rate, start_time, end_time): 81 | monitor = APIMonitor.objects.get(id=monitor_id) 82 | monitor_link = os.getenv('FRONTEND_URL') + f"/{monitor_id}/detail/" 83 | alerts_config, _ = AlertsConfiguration.objects.get_or_create(team=monitor.team) 84 | requests.post('https://slack.com/api/chat.postMessage', json= 85 | { 86 | "channel": alerts_config.slack_channel_id, 87 | "text": f"Success rate monitor <{monitor_link}|{monitor.name}> is dropping below {alerts_config.threshold_pct}%\nCurrent success rate from {start_time} - {end_time}: {success_rate}%\nYour monitor URL: {monitor.url}\nMethod: {monitor.method}" 88 | }, headers={ 89 | "Authorization": f"Bearer {alerts_config.slack_token}", 90 | "Content-Type": "application/json", 91 | }) 92 | 93 | def send_alert_discord(self, monitor_id, success_rate, start_time, end_time): 94 | monitor = APIMonitor.objects.get(id=monitor_id) 95 | alerts_config, _ = AlertsConfiguration.objects.get_or_create(team=monitor.team) 96 | discord_webhook_url = alerts_config.discord_webhook_url 97 | webhook = DiscordWebhook(url=discord_webhook_url, rate_limit_retry=True) 98 | embed = DiscordEmbed(title=f"Your API Monitor ({monitor.name}) Recently failed!", 99 | description=f"Your API Monitor failed to reach {alerts_config.threshold_pct}% success rate", 100 | url=os.getenv('FRONTEND_URL') + f"/{monitor_id}/detail/" 101 | ) 102 | 103 | embed.add_embed_field(name='Threshold', value=f"{alerts_config.threshold_pct}%") 104 | embed.add_embed_field(name=f'Current Success rate', value=f"{success_rate}%") 105 | embed.add_embed_field(name='Success rate range', value=f"{start_time} - {end_time}") 106 | embed.add_embed_field(name='Method', value=f"{monitor.method}") 107 | embed.add_embed_field(name="Url", value=f"{monitor.url}") 108 | embed.set_timestamp() 109 | 110 | webhook.add_embed(embed) 111 | webhook.execute() 112 | 113 | def send_alert_pagerduty(self, monitor_id, success_rate, start_time, end_time): 114 | monitor = APIMonitor.objects.get(id=monitor_id) 115 | alerts_config, _ = AlertsConfiguration.objects.get_or_create(team=monitor.team) 116 | 117 | requests.post('https://api.pagerduty.com/incidents', json={ 118 | "incident": { 119 | "type": "incident", 120 | "title": f"Success rate monitor {monitor.name} is dropping below {alerts_config.threshold_pct}%", 121 | "service": { 122 | "id": alerts_config.pagerduty_service_id, 123 | "type": "service_reference" 124 | }, 125 | "body": { 126 | "type": "incident_body", 127 | "details": f"Success rate is dropping below {alerts_config.threshold_pct}%\nCurrent success rate from {start_time} - {end_time}: {success_rate}" 128 | } 129 | } 130 | }, headers={ 131 | "Authorization": f"Token token={alerts_config.pagerduty_api_key}", 132 | "From": alerts_config.pagerduty_default_from_email, 133 | "Content-Type": "application/json", 134 | "Accept": "application/vnd.pagerduty+json;version=2", 135 | }) 136 | 137 | def send_alert_email(self, monitor_id, success_rate, start_time, end_time): 138 | monitor = APIMonitor.objects.get(id=monitor_id) 139 | alerts_config, _ = AlertsConfiguration.objects.get_or_create(team=monitor.team) 140 | error_logs_link = f"{os.environ.get('FRONTEND_URL', '')}/error-logs/" 141 | 142 | email_subject = f"Alerts on monitor {monitor.name}! - MonAPI" 143 | email_content = f''' 144 | Success rate monitor {monitor.name} is dropping below {alerts_config.threshold_pct}%\n 145 | Current success rate from {start_time} - {end_time} is {success_rate}% 146 | You can check the error logs in\n 147 | {error_logs_link} 148 | ''' 149 | from_email = f"{alerts_config.email_name} <{alerts_config.email_address}>" 150 | email_content_html = render_to_string('email/alerts.html', { 151 | 'api_monitor_name': monitor.name, 152 | 'threshold_pct': alerts_config.threshold_pct, 153 | 'error_logs_link': error_logs_link, 154 | 'success_rate': success_rate, 155 | 'start_time': start_time, 156 | 'end_time': end_time, 157 | }) 158 | 159 | with get_connection( 160 | host = alerts_config.email_host, 161 | port = alerts_config.email_port, 162 | username = alerts_config.email_username, 163 | password = alerts_config.email_password, 164 | use_tls = alerts_config.email_use_tls, 165 | use_ssl = alerts_config.email_use_ssl 166 | ) as connection: 167 | team_members = TeamMember.objects.filter(team=monitor.team) 168 | for member in team_members: 169 | member.user.email_user( 170 | email_subject, 171 | email_content, 172 | from_email, 173 | fail_silently=False, 174 | connection=connection, 175 | html_message=email_content_html, 176 | ) 177 | 178 | def worker(self, type): 179 | monitor_id = None 180 | 181 | while True: 182 | try: 183 | monitor_id = self.get_monitor_id_from_queue(type) 184 | if monitor_id == None: 185 | return 186 | 187 | print(f"[{timezone.now()}] Send {type} alert for monitor id:{monitor_id}") 188 | 189 | monitor = APIMonitor.objects.get(id=monitor_id) 190 | success_rate, start_time, end_time = self.get_success_rate(monitor) 191 | alerts_config, _ = AlertsConfiguration.objects.get_or_create(team=monitor.team) 192 | 193 | if type == 'slack' and alerts_config.is_slack_active: 194 | self.send_alert_slack(monitor_id, success_rate, start_time, end_time) 195 | elif type == 'discord' and alerts_config.is_discord_active: 196 | self.send_alert_discord(monitor_id, success_rate,start_time, end_time) 197 | elif type == 'pagerduty' and alerts_config.is_pagerduty_active: 198 | self.send_alert_pagerduty(monitor_id, success_rate,start_time, end_time) 199 | elif type == 'email' and alerts_config.is_email_active: 200 | self.send_alert_email(monitor_id, success_rate,start_time, end_time) 201 | 202 | print(f"[{timezone.now()}] Done send {type} alert for monitor id:{monitor_id}") 203 | 204 | except Exception as e: 205 | print(e) 206 | 207 | if monitor_id != None: 208 | self.queue[type].task_done() 209 | 210 | def handle(self, *args, **kwargs): 211 | self.stop_signal.clear() 212 | thread_pool = [] 213 | 214 | consumer_count = 1 215 | try: 216 | consumer_count = int(os.getenv('CRON_ALERTS_THREAD_COUNT', 1)) 217 | except ValueError: 218 | pass 219 | 220 | cron_interval = int(os.environ.get('CRON_INTERVAL_IN_SECONDS', 60)) 221 | 222 | for channel in self.channels: 223 | self.queue[channel] = queue.Queue() 224 | for _ in range(consumer_count): 225 | consumer = threading.Thread(target=self.worker, args=[channel]) 226 | consumer.start() 227 | thread_pool.append(consumer) 228 | 229 | try: 230 | # Cron loop function 231 | while True: 232 | last_run = timezone.now() 233 | api_monitors = APIMonitor.objects.all() 234 | 235 | for monitor in api_monitors: 236 | if monitor.last_notified != None and monitor.last_notified > timezone.now() - timedelta(minutes=5): 237 | continue 238 | 239 | alerts_config, _ = AlertsConfiguration.objects.get_or_create(team=monitor.team) 240 | success_rate,_,_ = self.get_success_rate(monitor) 241 | 242 | if success_rate < alerts_config.threshold_pct: 243 | self.put_monitor_id_into_queue(monitor.id) 244 | monitor.last_notified = timezone.now() 245 | monitor.save() 246 | 247 | # Add delay before next check 248 | mock_cron_interrupt() 249 | next_run = last_run + timedelta(seconds=cron_interval) 250 | sleep_duration = (next_run - timezone.now()).total_seconds() 251 | time.sleep(max(sleep_duration, 0)) 252 | except BaseException as e: 253 | # Gracefully shutdown thread worker 254 | for channel in self.channels: 255 | q = self.queue[channel] 256 | q.join() 257 | 258 | self.stop_signal.set() 259 | for thread in thread_pool: 260 | thread.join() 261 | raise e 262 | -------------------------------------------------------------------------------- /alerts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | # Create your models here. 3 | -------------------------------------------------------------------------------- /alerts/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from apimonitor.models import AlertsConfiguration 4 | 5 | class AlertsConfigurationSerializer(serializers.ModelSerializer): 6 | class Meta: 7 | model = AlertsConfiguration 8 | fields = [ 9 | 'utc', 10 | 'is_slack_active', 11 | 'slack_token', 12 | 'slack_channel_id', 13 | 'is_discord_active', 14 | 'discord_webhook_url', 15 | 'is_pagerduty_active', 16 | 'pagerduty_api_key', 17 | 'pagerduty_default_from_email', 18 | 'pagerduty_service_id', 19 | 'is_email_active', 20 | 'email_name', 21 | 'email_address', 22 | 'email_host', 23 | 'email_port', 24 | 'email_username', 25 | 'email_password', 26 | 'email_use_tls', 27 | 'email_use_ssl', 28 | 'threshold_pct', 29 | 'time_window', 30 | ] 31 | -------------------------------------------------------------------------------- /alerts/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from rest_framework import routers 3 | 4 | from alerts.views import AlertConfiguration 5 | 6 | urlpatterns = [ 7 | path('config/', AlertConfiguration.as_view(), name='alert-configuration'), 8 | ] 9 | -------------------------------------------------------------------------------- /alerts/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import views, status 2 | from rest_framework.permissions import IsAuthenticated 3 | from rest_framework.response import Response 4 | 5 | from alerts.serializers import AlertsConfigurationSerializer 6 | from apimonitor.models import AlertsConfiguration 7 | 8 | class AlertConfiguration(views.APIView): 9 | permission_classes = [IsAuthenticated] 10 | 11 | def get(self, request, format=None): 12 | config, _ = AlertsConfiguration.objects.get_or_create(team=request.auth.team) 13 | serializer = AlertsConfigurationSerializer(config) 14 | return Response(serializer.data) 15 | 16 | def post(self, request, format=None): 17 | config, _ = AlertsConfiguration.objects.get_or_create(team=request.auth.team) 18 | serializer = AlertsConfigurationSerializer(config, data=request.data, partial=True) 19 | if serializer.is_valid(): 20 | serializer.save() 21 | return Response(serializer.data) 22 | 23 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 24 | 25 | 26 | -------------------------------------------------------------------------------- /apimonitor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/apimonitor/__init__.py -------------------------------------------------------------------------------- /apimonitor/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from apimonitor.models import APIMonitor, APIMonitorBodyForm, APIMonitorRawBody, APIMonitorHeader, APIMonitorQueryParam, APIMonitorResult, AssertionExcludeKey, AlertsConfiguration 4 | 5 | 6 | admin.site.register(APIMonitor) 7 | admin.site.register(APIMonitorBodyForm) 8 | admin.site.register(APIMonitorRawBody) 9 | admin.site.register(APIMonitorHeader) 10 | admin.site.register(APIMonitorQueryParam) 11 | admin.site.register(APIMonitorResult) 12 | admin.site.register(AssertionExcludeKey) 13 | admin.site.register(AlertsConfiguration) 14 | -------------------------------------------------------------------------------- /apimonitor/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApimonitorConfig(AppConfig): 5 | name = 'apimonitor' 6 | -------------------------------------------------------------------------------- /apimonitor/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-09-20 08:10 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='APIMonitor', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=256)), 22 | ('method', models.CharField(choices=[('GET', 'GET'), ('POST', 'POST'), ('PATCH', 'PATCH'), ('PUT', 'PUST'), ('DELETE', 'DELETE')], max_length=16)), 23 | ('url', models.CharField(max_length=512)), 24 | ('schedule', models.CharField(choices=[('1MIN', '1 Minute'), ('2MIN', '2 Minute'), ('3MIN', '3 Minute'), ('5MIN', '5 Minute'), ('10MIN', '10 Minute'), ('15MIN', '15 Minute'), ('30MIN', '30 Minute'), ('60MIN', '60 Minute')], max_length=64)), 25 | ('body_type', models.CharField(choices=[('EMPTY', 'EMPTY'), ('FORM', 'FORM'), ('RAW', 'RAW')], max_length=16)), 26 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 27 | ], 28 | ), 29 | migrations.CreateModel( 30 | name='APIMonitorResult', 31 | fields=[ 32 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 33 | ('execution_time', models.DateTimeField()), 34 | ('date', models.DateField()), 35 | ('hour', models.IntegerField()), 36 | ('minute', models.IntegerField()), 37 | ('response_time', models.IntegerField()), 38 | ('success', models.BooleanField()), 39 | ('log_response', models.TextField()), 40 | ('monitor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='apimonitor.apimonitor')), 41 | ], 42 | ), 43 | migrations.CreateModel( 44 | name='APIMonitorRawBody', 45 | fields=[ 46 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 47 | ('body', models.TextField()), 48 | ('monitor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='raw_body', to='apimonitor.apimonitor')), 49 | ], 50 | ), 51 | migrations.CreateModel( 52 | name='APIMonitorQueryParam', 53 | fields=[ 54 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 55 | ('key', models.CharField(max_length=64)), 56 | ('value', models.CharField(max_length=1024)), 57 | ('monitor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='query_params', to='apimonitor.apimonitor')), 58 | ], 59 | ), 60 | migrations.CreateModel( 61 | name='APIMonitorHeader', 62 | fields=[ 63 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 64 | ('key', models.CharField(max_length=64)), 65 | ('value', models.CharField(max_length=1024)), 66 | ('monitor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='headers', to='apimonitor.apimonitor')), 67 | ], 68 | ), 69 | migrations.CreateModel( 70 | name='APIMonitorBodyForm', 71 | fields=[ 72 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 73 | ('key', models.CharField(max_length=64)), 74 | ('value', models.CharField(max_length=1024)), 75 | ('monitor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='body_form', to='apimonitor.apimonitor')), 76 | ], 77 | ), 78 | ] 79 | -------------------------------------------------------------------------------- /apimonitor/migrations/0002_apimonitorresult_log_error_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-10-04 02:42 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('apimonitor', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='apimonitorresult', 16 | name='log_error', 17 | field=models.TextField(default=''), 18 | preserve_default=False, 19 | ), 20 | migrations.AlterField( 21 | model_name='apimonitorrawbody', 22 | name='monitor', 23 | field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='raw_body', to='apimonitor.apimonitor'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /apimonitor/migrations/0003_apimonitorresult_status_code.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-10-14 01:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('apimonitor', '0002_apimonitorresult_log_error_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='apimonitorresult', 15 | name='status_code', 16 | field=models.IntegerField(default=0), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /apimonitor/migrations/0004_remove_apimonitorresult_date_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-10-14 12:58 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('apimonitor', '0003_apimonitorresult_status_code'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='apimonitorresult', 15 | name='date', 16 | ), 17 | migrations.RemoveField( 18 | model_name='apimonitorresult', 19 | name='hour', 20 | ), 21 | migrations.RemoveField( 22 | model_name='apimonitorresult', 23 | name='minute', 24 | ), 25 | migrations.AddIndex( 26 | model_name='apimonitorresult', 27 | index=models.Index(fields=['monitor', 'execution_time'], name='result_time_index'), 28 | ), 29 | migrations.AddIndex( 30 | model_name='apimonitorresult', 31 | index=models.Index(fields=['monitor', 'success'], name='result_success_index'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /apimonitor/migrations/0005_apimonitor_assertion_value_apimonitor_assetion_type_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-10-17 17:34 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('apimonitor', '0004_remove_apimonitorresult_date_and_more'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='apimonitor', 18 | name='assertion_value', 19 | field=models.TextField(blank=True), 20 | ), 21 | migrations.AddField( 22 | model_name='apimonitor', 23 | name='assetion_type', 24 | field=models.CharField(choices=[('DISABLED', 'Disabled'), ('TEXT', 'Text'), ('JSON', 'JSON')], default='DISABLED', max_length=16), 25 | ), 26 | migrations.AddField( 27 | model_name='apimonitor', 28 | name='previous_step', 29 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='apimonitor.apimonitor'), 30 | ), 31 | migrations.CreateModel( 32 | name='AlertsConfiguration', 33 | fields=[ 34 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 35 | ('is_slack_active', models.BooleanField(default=False)), 36 | ('slack_token', models.CharField(blank=True, default='', max_length=256)), 37 | ('slack_channel_id', models.CharField(blank=True, default='', max_length=256)), 38 | ('is_discord_active', models.BooleanField(default=False)), 39 | ('discord_bot_token', models.CharField(blank=True, default='', max_length=256)), 40 | ('discord_guild_id', models.CharField(blank=True, default='', max_length=256)), 41 | ('discord_channel_id', models.CharField(blank=True, default='', max_length=256)), 42 | ('is_pagerduty_active', models.BooleanField(default=False)), 43 | ('pagerduty_api_key', models.CharField(blank=True, default='', max_length=256)), 44 | ('pagerduty_default_from_email', models.CharField(blank=True, default='', max_length=256)), 45 | ('is_email_active', models.BooleanField(default=False)), 46 | ('email_host', models.CharField(blank=True, default='', max_length=1024)), 47 | ('email_port', models.IntegerField(blank=True, null=True)), 48 | ('email_username', models.CharField(blank=True, default='', max_length=1024)), 49 | ('email_password', models.CharField(blank=True, default='', max_length=1024)), 50 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 51 | ], 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /apimonitor/migrations/0006_apimonitor_is_assert_json_schema_only_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-10-18 01:22 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('apimonitor', '0005_apimonitor_assertion_value_apimonitor_assetion_type_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='apimonitor', 16 | name='is_assert_json_schema_only', 17 | field=models.BooleanField(default=False), 18 | ), 19 | migrations.AlterField( 20 | model_name='apimonitor', 21 | name='previous_step', 22 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='apimonitor.apimonitor'), 23 | ), 24 | migrations.CreateModel( 25 | name='AssertionExcludeKey', 26 | fields=[ 27 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('exclude_key', models.CharField(max_length=1024)), 29 | ('monitor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='apimonitor.apimonitor')), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /apimonitor/migrations/0007_rename_assetion_type_apimonitor_assertion_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-10-19 14:44 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('apimonitor', '0006_apimonitor_is_assert_json_schema_only_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='apimonitor', 15 | old_name='assetion_type', 16 | new_name='assertion_type', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /apimonitor/migrations/0008_alter_assertionexcludekey_monitor.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-10-24 09:55 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('apimonitor', '0007_rename_assetion_type_apimonitor_assertion_type'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='assertionexcludekey', 16 | name='monitor', 17 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exclude_keys', to='apimonitor.apimonitor'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /apimonitor/migrations/0009_alertsconfiguration_email_use_ssl_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-10-29 08:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('apimonitor', '0008_alter_assertionexcludekey_monitor'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='alertsconfiguration', 15 | name='email_use_ssl', 16 | field=models.BooleanField(default=False), 17 | ), 18 | migrations.AddField( 19 | model_name='alertsconfiguration', 20 | name='email_use_tls', 21 | field=models.BooleanField(default=False), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /apimonitor/migrations/0010_alertsconfiguration_threshold_pct_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-10-31 08:51 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('apimonitor', '0009_alertsconfiguration_email_use_ssl_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='alertsconfiguration', 16 | name='threshold_pct', 17 | field=models.IntegerField(blank=True, default=100, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)]), 18 | ), 19 | migrations.AddField( 20 | model_name='alertsconfiguration', 21 | name='time_window', 22 | field=models.CharField(choices=[('1H', '1 Hour'), ('2H', '2 Hour'), ('3H', '3 Hour'), ('6H', '6 Hour'), ('12H', '12 Hour'), ('24H', '24 Hour')], default='1H', max_length=16), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /apimonitor/migrations/0011_alertsconfiguration_pagerduty_service_id_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-01 04:56 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('apimonitor', '0010_alertsconfiguration_threshold_pct_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='alertsconfiguration', 16 | name='pagerduty_service_id', 17 | field=models.CharField(blank=True, default='', max_length=64), 18 | ), 19 | migrations.AddField( 20 | model_name='apimonitor', 21 | name='last_notified', 22 | field=models.DateTimeField(blank=True, null=True), 23 | ), 24 | migrations.AlterField( 25 | model_name='alertsconfiguration', 26 | name='threshold_pct', 27 | field=models.IntegerField(default=100, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)]), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /apimonitor/migrations/0012_alertsconfiguration_email_address_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-02 13:31 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('apimonitor', '0011_alertsconfiguration_pagerduty_service_id_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='alertsconfiguration', 15 | name='email_address', 16 | field=models.EmailField(blank=True, max_length=1024), 17 | ), 18 | migrations.AddField( 19 | model_name='alertsconfiguration', 20 | name='email_name', 21 | field=models.CharField(blank=True, default='', max_length=1024), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /apimonitor/migrations/0012_rename_discord_guild_id_alertsconfiguration_discord_webhook_url_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-02 08:04 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('apimonitor', '0011_alertsconfiguration_pagerduty_service_id_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='alertsconfiguration', 15 | old_name='discord_guild_id', 16 | new_name='discord_webhook_url', 17 | ), 18 | migrations.RemoveField( 19 | model_name='alertsconfiguration', 20 | name='discord_bot_token', 21 | ), 22 | migrations.RemoveField( 23 | model_name='alertsconfiguration', 24 | name='discord_channel_id', 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /apimonitor/migrations/0013_merge_20221106_1259.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-06 05:59 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('apimonitor', '0012_alertsconfiguration_email_address_and_more'), 10 | ('apimonitor', '0012_rename_discord_guild_id_alertsconfiguration_discord_webhook_url_and_more'), 11 | ] 12 | 13 | operations = [ 14 | ] 15 | -------------------------------------------------------------------------------- /apimonitor/migrations/0014_alertsconfiguration_utc.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-15 14:10 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("apimonitor", "0013_merge_20221106_1259"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="alertsconfiguration", 16 | name="utc", 17 | field=models.IntegerField( 18 | default=7, 19 | validators=[ 20 | django.core.validators.MinValueValidator(-12), 21 | django.core.validators.MaxValueValidator(14), 22 | ], 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /apimonitor/migrations/0014_remove_alertsconfiguration_user_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-11-15 03:32 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | def forwards_func(apps, schema_editor): 9 | # We get the model from the versioned app registry; 10 | # if we directly import it, it'll be the wrong version 11 | db_alias = schema_editor.connection.alias 12 | 13 | Team = apps.get_model("login", "Team") 14 | TeamMember = apps.get_model("login", "TeamMember") 15 | User = apps.get_model("auth", "User") 16 | 17 | users = User.objects.using(db_alias).all() 18 | team_mapping = {} 19 | for user in users: 20 | username = user.username.split('@')[0] 21 | team = Team.objects.using(db_alias).create(name=username.title()) 22 | TeamMember.objects.using(db_alias).create(team=team, user=user) 23 | team_mapping[user.id] = team 24 | 25 | APIMonitor = apps.get_model("apimonitor", "APIMonitor") 26 | api_monitor = APIMonitor.objects.using(db_alias).all() 27 | for monitor in api_monitor: 28 | monitor.team = team_mapping[monitor.user.id] 29 | monitor.save() 30 | 31 | AlertsConfiguration = apps.get_model("apimonitor", "AlertsConfiguration") 32 | configs = AlertsConfiguration.objects.using(db_alias).all() 33 | for config in configs: 34 | config.team = team_mapping[config.user.id] 35 | config.save() 36 | 37 | 38 | class Migration(migrations.Migration): 39 | 40 | dependencies = [ 41 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 42 | ('login', '0002_alter_team_description_alter_team_logo'), 43 | ('apimonitor', '0013_merge_20221106_1259'), 44 | ] 45 | 46 | operations = [ 47 | migrations.AddField( 48 | model_name='alertsconfiguration', 49 | name='team', 50 | field=models.OneToOneField(null=True, blank=True, on_delete=django.db.models.deletion.CASCADE, to='login.team'), 51 | ), 52 | migrations.AddField( 53 | model_name='apimonitor', 54 | name='team', 55 | field=models.ForeignKey(null=True, blank=True, on_delete=django.db.models.deletion.CASCADE, to='login.team'), 56 | ), 57 | migrations.RunPython( 58 | forwards_func, 59 | ), 60 | migrations.RemoveField( 61 | model_name='alertsconfiguration', 62 | name='user', 63 | ), 64 | migrations.RemoveField( 65 | model_name='apimonitor', 66 | name='user', 67 | ), 68 | ] 69 | -------------------------------------------------------------------------------- /apimonitor/migrations/0015_alter_alertsconfiguration_team_alter_apimonitor_team.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2022-11-15 04:00 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('login', '0002_alter_team_description_alter_team_logo'), 11 | ('apimonitor', '0014_remove_alertsconfiguration_user_and_more'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='alertsconfiguration', 17 | name='team', 18 | field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='login.team'), 19 | ), 20 | migrations.AlterField( 21 | model_name='apimonitor', 22 | name='team', 23 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='login.team'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /apimonitor/migrations/0016_merge_20221116_1054.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-16 03:54 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('apimonitor', '0014_alertsconfiguration_utc'), 10 | ('apimonitor', '0015_alter_alertsconfiguration_team_alter_apimonitor_team'), 11 | ] 12 | 13 | operations = [ 14 | ] 15 | -------------------------------------------------------------------------------- /apimonitor/migrations/0017_apimonitor_status_page_category.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-29 02:00 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('statuspage', '0001_initial'), 11 | ('apimonitor', '0016_merge_20221116_1054'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='apimonitor', 17 | name='status_page_category', 18 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='statuspage.statuspagecategory'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /apimonitor/migrations/0018_alter_apimonitor_assertion_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-12-09 04:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('apimonitor', '0017_apimonitor_status_page_category'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='apimonitor', 15 | name='assertion_type', 16 | field=models.CharField(choices=[('DISABLED', 'Disabled'), ('TEXT', 'Text'), ('JSON', 'JSON'), ('PARTIAL', 'Partial')], default='DISABLED', max_length=16), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /apimonitor/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/apimonitor/migrations/__init__.py -------------------------------------------------------------------------------- /apimonitor/models.py: -------------------------------------------------------------------------------- 1 | from io import open_code 2 | from django.db import models 3 | from django.contrib.auth.models import User 4 | from django.core.validators import MinValueValidator, MaxValueValidator 5 | from login.models import Team 6 | from statuspage.models import StatusPageCategory 7 | 8 | class APIMonitor(models.Model): 9 | method_choices = [ 10 | ('GET', 'GET'), 11 | ('POST', 'POST'), 12 | ('PATCH', 'PATCH'), 13 | ('PUT', 'PUST'), 14 | ('DELETE', 'DELETE'), 15 | ] 16 | 17 | body_type_choices = [ 18 | ('EMPTY', 'EMPTY'), 19 | ('FORM', 'FORM'), 20 | ('RAW', 'RAW') 21 | ] 22 | 23 | schedule_choices = [ 24 | ('1MIN', '1 Minute'), 25 | ('2MIN', '2 Minute'), 26 | ('3MIN', '3 Minute'), 27 | ('5MIN', '5 Minute'), 28 | ('10MIN', '10 Minute'), 29 | ('15MIN', '15 Minute'), 30 | ('30MIN', '30 Minute'), 31 | ('60MIN', '60 Minute'), 32 | ] 33 | 34 | assertion_type_choices = [ 35 | ('DISABLED', 'Disabled'), 36 | ('TEXT', 'Text'), 37 | ('JSON', 'JSON'), 38 | ('PARTIAL', 'Partial'), 39 | ] 40 | 41 | team = models.ForeignKey(Team, on_delete=models.CASCADE) 42 | name = models.CharField(max_length=256) 43 | method = models.CharField(max_length=16, choices=method_choices) 44 | url = models.CharField(max_length=512) 45 | schedule = models.CharField(max_length=64, choices=schedule_choices) 46 | body_type = models.CharField(max_length=16, choices=body_type_choices) 47 | previous_step = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True) 48 | assertion_type = models.CharField(max_length=16, choices=assertion_type_choices, default='DISABLED') 49 | assertion_value = models.TextField(blank=True) 50 | is_assert_json_schema_only = models.BooleanField(default=False) 51 | last_notified = models.DateTimeField(null=True, blank=True) 52 | status_page_category = models.ForeignKey(StatusPageCategory, null=True, blank=True, on_delete=models.SET_NULL) 53 | 54 | 55 | class APIMonitorQueryParam(models.Model): 56 | monitor = models.ForeignKey(APIMonitor, on_delete=models.CASCADE, related_name='query_params') 57 | key = models.CharField(max_length=64) 58 | value = models.CharField(max_length=1024) 59 | 60 | 61 | class APIMonitorHeader(models.Model): 62 | monitor = models.ForeignKey(APIMonitor, on_delete=models.CASCADE, related_name='headers') 63 | key = models.CharField(max_length=64) 64 | value = models.CharField(max_length=1024) 65 | 66 | 67 | class APIMonitorBodyForm(models.Model): 68 | monitor = models.ForeignKey(APIMonitor, on_delete=models.CASCADE, related_name='body_form') 69 | key = models.CharField(max_length=64) 70 | value = models.CharField(max_length=1024) 71 | 72 | 73 | class APIMonitorRawBody(models.Model): 74 | monitor = models.OneToOneField(APIMonitor, on_delete=models.CASCADE, related_name='raw_body') 75 | body = models.TextField() 76 | 77 | 78 | class APIMonitorResult(models.Model): 79 | monitor = models.ForeignKey(APIMonitor, on_delete=models.CASCADE, related_name='results') 80 | execution_time = models.DateTimeField() 81 | response_time = models.IntegerField() # in miliseconds 82 | success = models.BooleanField() 83 | status_code = models.IntegerField() 84 | log_response = models.TextField() 85 | log_error = models.TextField() 86 | 87 | class Meta: 88 | indexes = [ 89 | models.Index(fields=['monitor', 'execution_time'], name='result_time_index'), 90 | models.Index(fields=['monitor', 'success'], name='result_success_index'), 91 | ] 92 | 93 | 94 | class AssertionExcludeKey(models.Model): 95 | monitor = models.ForeignKey(APIMonitor, on_delete=models.CASCADE, related_name='exclude_keys') 96 | exclude_key = models.CharField(max_length=1024) 97 | 98 | 99 | class AlertsConfiguration(models.Model): 100 | team = models.OneToOneField(Team, on_delete=models.CASCADE) 101 | 102 | utc = models.IntegerField(default=7, validators=[ 103 | MinValueValidator(-12), 104 | MaxValueValidator(14) 105 | ]) 106 | 107 | # Slack config 108 | is_slack_active = models.BooleanField(default=False) 109 | slack_token = models.CharField(max_length=256, blank=True, default="") 110 | slack_channel_id = models.CharField(max_length=256, blank=True, default="") 111 | 112 | # Discord config 113 | is_discord_active = models.BooleanField(default=False) 114 | discord_webhook_url = models.CharField(max_length=256, blank=True, default="") 115 | 116 | # Pagerduty config 117 | is_pagerduty_active = models.BooleanField(default=False) 118 | pagerduty_api_key = models.CharField(max_length=256, blank=True, default="") 119 | pagerduty_default_from_email = models.CharField(max_length=256, blank=True, default="") 120 | pagerduty_service_id = models.CharField(max_length=64, blank=True, default="") 121 | 122 | # Email config 123 | is_email_active = models.BooleanField(default=False) 124 | email_name = models.CharField(max_length=1024, blank=True, default="") 125 | email_address = models.EmailField(max_length = 1024, blank=True) 126 | email_host = models.CharField(max_length=1024, blank=True, default="") 127 | email_port = models.IntegerField(null=True, blank=True) 128 | email_username = models.CharField(max_length=1024, blank=True, default="") 129 | email_password = models.CharField(max_length=1024, blank=True, default="") 130 | email_use_tls = models.BooleanField(default=False) 131 | email_use_ssl = models.BooleanField(default=False) 132 | 133 | # Threshold config 134 | time_window_choices = [ 135 | ('1H', '1 Hour'), 136 | ('2H', '2 Hour'), 137 | ('3H', '3 Hour'), 138 | ('6H', '6 Hour'), 139 | ('12H', '12 Hour'), 140 | ('24H', '24 Hour') 141 | ] 142 | threshold_pct = models.IntegerField(default=100, validators=[ 143 | MinValueValidator(1), 144 | MaxValueValidator(100) 145 | ]) 146 | time_window = models.CharField(max_length=16, choices=time_window_choices, default='1H') 147 | -------------------------------------------------------------------------------- /apimonitor/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from apimonitor.models import APIMonitor, APIMonitorQueryParam, APIMonitorHeader, APIMonitorRawBody, \ 4 | APIMonitorResult, AssertionExcludeKey 5 | from statuspage.serializers import StatusPageCategorySerializers 6 | 7 | class APIMonitorQueryParamSerializer(serializers.ModelSerializer): 8 | class Meta: 9 | model = APIMonitorQueryParam 10 | fields = [ 11 | 'id', 12 | 'monitor', 13 | 'key', 14 | 'value', 15 | ] 16 | 17 | 18 | class APIMonitorHeaderSerializer(serializers.ModelSerializer): 19 | class Meta: 20 | model = APIMonitorHeader 21 | fields = [ 22 | 'id', 23 | 'monitor', 24 | 'key', 25 | 'value', 26 | ] 27 | 28 | 29 | class APIMonitorBodyFormSerializer(serializers.ModelSerializer): 30 | class Meta: 31 | model = APIMonitorHeader 32 | fields = [ 33 | 'id', 34 | 'monitor', 35 | 'key', 36 | 'value', 37 | ] 38 | 39 | 40 | class APIMonitorRawBodySerializer(serializers.ModelSerializer): 41 | class Meta: 42 | model = APIMonitorRawBody 43 | fields = [ 44 | 'id', 45 | 'monitor', 46 | 'body', 47 | ] 48 | 49 | 50 | 51 | class AssertionExcludeKeySerializer(serializers.ModelSerializer): 52 | class Meta: 53 | model = AssertionExcludeKey 54 | fields = [ 55 | 'id', 56 | 'monitor', 57 | 'exclude_key', 58 | ] 59 | 60 | 61 | class APIMonitorDetailSuccessRateSerializer(serializers.Serializer): 62 | start_time = serializers.DateTimeField() 63 | end_time = serializers.DateTimeField() 64 | success = serializers.IntegerField() 65 | failed = serializers.IntegerField() 66 | 67 | 68 | class APIMonitorDetailResponseTimeSerializer(serializers.Serializer): 69 | start_time = serializers.DateTimeField() 70 | end_time = serializers.DateTimeField() 71 | avg = serializers.IntegerField() 72 | 73 | 74 | class APIMonitorSerializer(serializers.ModelSerializer): 75 | query_params = APIMonitorQueryParamSerializer(many=True, required=False, allow_null=True) 76 | headers = APIMonitorHeaderSerializer(many=True, required=False, allow_null=True) 77 | body_form = APIMonitorBodyFormSerializer(many=True, required=False, allow_null=True) 78 | raw_body = APIMonitorRawBodySerializer(required=False, allow_null=True) 79 | previous_step_id = serializers.PrimaryKeyRelatedField(read_only=True, many=False) 80 | status_page_category_id = serializers.PrimaryKeyRelatedField(read_only=True, many=False) 81 | exclude_keys = AssertionExcludeKeySerializer(many=True, required=False, allow_null=True) 82 | 83 | class Meta: 84 | model = APIMonitor 85 | fields = [ 86 | 'id', 87 | 'name', 88 | 'method', 89 | 'url', 90 | 'schedule', 91 | 'body_type', 92 | 'query_params', 93 | 'headers', 94 | 'body_form', 95 | 'raw_body', 96 | 'previous_step_id', 97 | 'assertion_type', 98 | 'assertion_value', 99 | 'is_assert_json_schema_only', 100 | 'exclude_keys', 101 | 'status_page_category_id', 102 | ] 103 | 104 | 105 | class APIMonitorResultSerializer(serializers.ModelSerializer): 106 | class Meta: 107 | model = APIMonitorResult 108 | fields = [ 109 | 'id', 110 | 'monitor', 111 | 'execution_time', 112 | 'response_time', 113 | 'success', 114 | 'status_code', 115 | 'log_response', 116 | 'log_error', 117 | ] 118 | 119 | 120 | class APIMonitorListSerializer(APIMonitorSerializer): 121 | success_rate = serializers.DecimalField(None, decimal_places=1) 122 | avg_response_time = serializers.IntegerField() 123 | success_rate_history = APIMonitorDetailSuccessRateSerializer(many=True) 124 | last_result = APIMonitorResultSerializer() 125 | 126 | class Meta: 127 | model = APIMonitor 128 | fields = [ 129 | 'id', 130 | 'name', 131 | 'method', 132 | 'url', 133 | 'schedule', 134 | 'body_type', 135 | 'query_params', 136 | 'headers', 137 | 'body_form', 138 | 'raw_body', 139 | 'success_rate', 140 | 'avg_response_time', 141 | 'success_rate_history', 142 | 'last_result', 143 | ] 144 | 145 | class APIMonitorDashboardSerializer(serializers.Serializer): 146 | success_rate = APIMonitorDetailSuccessRateSerializer(many=True) 147 | response_time = APIMonitorDetailResponseTimeSerializer(many=True) 148 | 149 | 150 | class APIMonitorRetrieveSerializer(APIMonitorSerializer): 151 | success_rate = APIMonitorDetailSuccessRateSerializer(many=True) 152 | response_time = APIMonitorDetailResponseTimeSerializer(many=True) 153 | status_page_category = StatusPageCategorySerializers() 154 | 155 | class Meta: 156 | model = APIMonitor 157 | fields = [ 158 | 'id', 159 | 'name', 160 | 'method', 161 | 'url', 162 | 'schedule', 163 | 'body_type', 164 | 'query_params', 165 | 'headers', 166 | 'body_form', 167 | 'raw_body', 168 | 'previous_step_id', 169 | 'success_rate', 170 | 'response_time', 171 | 'assertion_type', 172 | 'assertion_value', 173 | 'is_assert_json_schema_only', 174 | 'exclude_keys', 175 | 'status_page_category_id', 176 | 'status_page_category', 177 | ] 178 | -------------------------------------------------------------------------------- /apimonitor/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from rest_framework import routers 3 | 4 | from apimonitor import views 5 | 6 | router = routers.DefaultRouter() 7 | router.register(r'', views.APIMonitorViewSet, basename='api-monitor') 8 | 9 | urlpatterns = [ 10 | path('', include(router.urls)), 11 | ] 12 | -------------------------------------------------------------------------------- /apitest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/apitest/__init__.py -------------------------------------------------------------------------------- /apitest/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /apitest/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class APITestConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "apitest" 7 | -------------------------------------------------------------------------------- /apitest/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/apitest/migrations/__init__.py -------------------------------------------------------------------------------- /apitest/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /apitest/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from apimonitor.models import APIMonitor, APIMonitorQueryParam, APIMonitorHeader, APIMonitorRawBody, APIMonitorBodyForm, \ 4 | APIMonitorResult, AssertionExcludeKey 5 | 6 | class APITestQueryParamSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = APIMonitorQueryParam 9 | fields = [ 10 | 'key', 11 | 'value', 12 | ] 13 | 14 | class APITestHeaderSerializer(serializers.ModelSerializer): 15 | class Meta: 16 | model = APIMonitorHeader 17 | fields = [ 18 | 'key', 19 | 'value', 20 | ] 21 | 22 | class APITestBodyFormSerializer(serializers.ModelSerializer): 23 | class Meta: 24 | model = APIMonitorBodyForm 25 | fields = [ 26 | 'key', 27 | 'value', 28 | ] 29 | 30 | -------------------------------------------------------------------------------- /apitest/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from apitest import views 4 | 5 | urlpatterns = [ 6 | path('', views.APITestView.as_view(), name='api-test'), 7 | ] 8 | -------------------------------------------------------------------------------- /apitest/views.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from rest_framework import status, views 3 | from rest_framework.response import Response 4 | 5 | from apitest.serializers import (APITestQueryParamSerializer, APITestHeaderSerializer, APITestBodyFormSerializer) 6 | 7 | class APITestView(views.APIView): 8 | def post(self, request, format=None): 9 | monitor_data = { 10 | 'method': request.data.get('method'), 11 | 'url': request.data.get('url'), 12 | 'body_type': request.data.get('body_type'), 13 | 'query_params': {}, 14 | 'headers' : {}, 15 | 'body' : {}, 16 | } 17 | error_log = [] 18 | try: 19 | if request.data.get('query_params'): 20 | for key_value_pair in request.data.get('query_params'): 21 | if 'key' in key_value_pair and 'value' in key_value_pair: 22 | key, value = key_value_pair['key'], key_value_pair['value'] 23 | else: 24 | error_log += ["Please make sure you submit correct [query params]"] 25 | break 26 | record = { 27 | 'key': key, 28 | 'value': value 29 | } 30 | if APITestQueryParamSerializer(data=record).is_valid(): 31 | monitor_data['query_params'][key] = value 32 | else: 33 | error_log += ["Please make sure your [query params] key and value are valid strings!"] 34 | break 35 | 36 | if request.data.get('headers'): 37 | for key_value_pair in request.data.get('headers'): 38 | if 'key' in key_value_pair and 'value' in key_value_pair: 39 | key, value = key_value_pair['key'], key_value_pair['value'] 40 | else: 41 | error_log += ["Please make sure you submit correct [headers]"] 42 | break 43 | record = { 44 | 'key': key, 45 | 'value': value 46 | } 47 | if APITestHeaderSerializer(data=record).is_valid(): 48 | monitor_data['headers'][key] = value 49 | else: 50 | error_log += ["Please make sure your [headers] key and value are valid strings!"] 51 | break 52 | 53 | if request.data.get('body_type') == 'FORM': 54 | for key_value_pair in request.data.get('body_form'): 55 | if 'key' in key_value_pair and 'value' in key_value_pair: 56 | key, value = key_value_pair['key'], key_value_pair['value'] 57 | else: 58 | error_log += ["Please make sure you submit correct [body form]"] 59 | break 60 | record = { 61 | 'key': key, 62 | 'value': value 63 | } 64 | if APITestBodyFormSerializer(data=record).is_valid(): 65 | monitor_data['body'][key] = value 66 | else: 67 | error_log += ["Please make sure your [body form] key and value are valid strings!"] 68 | break 69 | elif request.data.get('body_type') == 'RAW': 70 | monitor_data['body']=request.data["raw_body"] 71 | 72 | assert len(error_log) == 0, error_log 73 | 74 | try: 75 | resp = requests.request(monitor_data['method'], monitor_data['url'], params=monitor_data['query_params'], data=monitor_data['body'], headers=monitor_data['headers'], timeout=30) 76 | 77 | except Exception as e: 78 | error_log += [str(e)] 79 | 80 | assert len(error_log) == 0, error_log 81 | 82 | return Response({ 83 | 'response': resp.content.decode('utf-8', errors='ignore') 84 | }) 85 | 86 | except AssertionError as e: 87 | return Response(data={"error": f"{e}"}, status=status.HTTP_400_BAD_REQUEST) 88 | -------------------------------------------------------------------------------- /cron/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/cron/__init__.py -------------------------------------------------------------------------------- /cron/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /cron/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CronConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'cron' 7 | -------------------------------------------------------------------------------- /cron/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/cron/migrations/__init__.py -------------------------------------------------------------------------------- /cron/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | ## How to start development server 2 | > Please adjust command `pip` and `python` accordingly to your machine (can be `pip3` or `python3`) 3 | 1. Install python virtualenv package if you don't have on local by running `python -m pip install virtualenv` 4 | 2. Create new virtual environment for this python project by running `virtualenv venv` 5 | 3. Activate python environment by running `venv\Scripts\activate` for Windows or `source venv/bin/activate` for Mac and Linux 6 | 4. Clone this repository to your local machine 7 | 5. Change directory of CLI to project root folder 8 | 6. Copy `.env.example` file to `.env` on the project root folder 9 | 7. Run `pip install -r requirements.txt` to install all needed requirements for this project 10 | 8. Run database migrations by using `python manage.py migrate` 11 | 9. Run local server by using `python manage.py runserver` 12 | 10. You can access local server on http://localhost:8000 or http://127.0.0.1:8000 13 | 14 | ## How to run tests 15 | 1. You can run `python manage.py test` or using make command `make test` 16 | 17 | ## How to run test coverage report 18 | 1. You can run `make test-cover` 19 | -------------------------------------------------------------------------------- /docs/release_notes.md: -------------------------------------------------------------------------------- 1 | ## Release Notes 2 | 3 | ### Pre Release 4 | Version: Pre-release
5 | Date: 16th September 2022 6 | 1. Staging server is now officially online! checkout on https://api-staging.monapi.xyz 7 | 2. Our blog is now officially online! checkout on https://blog.monapi.xyz 8 | 9 | ### Release 1 10 | Version: v0.1.0
11 | Date: 25th September 2022 12 | 1. Add all models needed for api monitor 13 | 2. Implement authentication method, including login and register 14 | 3. Implement view list api monitor endpoint 15 | 16 | ### Release 2 17 | Version: v0.2.0
18 | Date: 17th October 2022 19 | 1. Implement create new api monitor 20 | 2. Implement api monitor details 21 | 3. Run cron for api monitor 22 | 23 | ### Release 3 24 | Version: v0.3.0
25 | Date: 31th October 2022 26 | 1. Edit API Monitor 27 | 2. API Monitor Assertions 28 | 3. Multi step API monitor 29 | 4. Alerts configuration 30 | 31 | ### Release 4 32 | Version: v0.4.0
33 | Date: 13th November 2022 34 | 1. Alerts integration 35 | 2. Forget Password 36 | 3. Test API 37 | 38 | ### Release 5 39 | Version: v0.5.0
40 | Date: 28th November 2022 41 | 1. Team Management 42 | 2. Alerts User-Defined Timezone 43 | 44 | ### Release 6 45 | Version: v1.0.0
46 | Date: 12th December 2022 47 | 1. Status Page Integration 48 | 2. Email register verification 49 | 3. Create new category directly when create API Monitor 50 | 4. Partial Assertions Text 51 | 5. Release 1st version of MonAPI 52 | 53 | 54 | ### Release 7 55 | Version: v1.0.1
56 | Date: 28th December 2022 57 | 1. Fix bug password validation on forget password 58 | 2. Update pagerduty email configuration title -------------------------------------------------------------------------------- /error_logs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/error_logs/__init__.py -------------------------------------------------------------------------------- /error_logs/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /error_logs/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ErrorLogsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'error_logs' 7 | -------------------------------------------------------------------------------- /error_logs/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/error_logs/migrations/__init__.py -------------------------------------------------------------------------------- /error_logs/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /error_logs/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from apimonitor.models import APIMonitorResult 4 | from apimonitor.serializers import APIMonitorSerializer 5 | 6 | class ErrorLogsSerializer(serializers.ModelSerializer): 7 | monitor = APIMonitorSerializer() 8 | 9 | class Meta: 10 | model = APIMonitorResult 11 | fields = [ 12 | 'id', 13 | 'monitor', 14 | 'execution_time', 15 | 'response_time', 16 | 'success', 17 | 'status_code', 18 | 'log_response', 19 | 'log_error', 20 | ] -------------------------------------------------------------------------------- /error_logs/tests.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | import json 3 | from datetime import datetime 4 | 5 | from django.conf import settings 6 | from django.contrib.auth.models import User 7 | from django.urls import reverse 8 | from django.utils import timezone 9 | from rest_framework import status 10 | from rest_framework.test import APITestCase 11 | from rest_framework.authtoken.models import Token 12 | 13 | from apimonitor.models import APIMonitor, APIMonitorResult 14 | from login.models import Team, TeamMember, MonAPIToken 15 | 16 | class ListErrorLogs(APITestCase): 17 | url = reverse('error-logs-list') 18 | local_timezone = pytz.timezone(settings.TIME_ZONE) 19 | mock_current_time = local_timezone.localize(datetime(2022, 9, 20, 10)) 20 | 21 | def setUp(self): 22 | # Mock time function 23 | timezone.now = lambda: self.mock_current_time 24 | 25 | def test_when_non_authenticated_then_return_unauthorized(self): 26 | response = self.client.get(self.url, format='json') 27 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 28 | self.assertEqual(response.data, { 29 | "detail": "Authentication credentials were not provided." 30 | }) 31 | 32 | def test_when_authenticated_and_empty_data_then_empty_error_logs_success(self): 33 | # Create dummy user and authenticate 34 | user = User.objects.create_user(username='test', email='test@test.com', password='test123') 35 | team = Team.objects.create(name='test team') 36 | team_member = TeamMember.objects.create(team=team, user=user) 37 | 38 | token = MonAPIToken.objects.create(team_member=team_member) 39 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 40 | 41 | response = self.client.get(self.url, format='json', **header) 42 | self.assertEqual(response.status_code, status.HTTP_200_OK) 43 | self.assertEqual(response.data, {"count": 0, "next": None, "previous": None, "results": []}) 44 | 45 | def test_when_authenticated_and_data_are_exists_then_view_error_logs_list_success(self): 46 | # Create dummy user and authenticate 47 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 48 | team = Team.objects.create(name='test team') 49 | team_member = TeamMember.objects.create(team=team, user=user) 50 | 51 | token = MonAPIToken.objects.create(team_member=team_member) 52 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 53 | 54 | monitor = APIMonitor.objects.create( 55 | team=team, 56 | name='Test Name', 57 | method='GET', 58 | url='Test Path', 59 | schedule='10MIN', 60 | previous_step=None, 61 | body_type='EMPTY', 62 | ) 63 | 64 | APIMonitorResult.objects.create( 65 | monitor=monitor, 66 | execution_time=self.mock_current_time, 67 | response_time=100, 68 | success=False, 69 | status_code=500, 70 | log_response='Log Response', 71 | log_error='', 72 | ) 73 | 74 | APIMonitorResult.objects.create( 75 | monitor=monitor, 76 | execution_time=self.mock_current_time, 77 | response_time=75, 78 | success=False, 79 | status_code=500, 80 | log_response='', 81 | log_error='Log Error' 82 | ) 83 | 84 | response = self.client.get(self.url, format='json', **header) 85 | self.assertEqual(response.status_code, status.HTTP_200_OK) 86 | self.assertEqual(response.data['count'], 2) 87 | self.assertEqual(response.data['results'], [ 88 | { 89 | "id": 1, 90 | "monitor": { 91 | "id": 1, 92 | "name": "Test Name", 93 | "method": "GET", 94 | "url": "Test Path", 95 | "schedule": "10MIN", 96 | "body_type": "EMPTY", 97 | "query_params": [], 98 | "headers": [], 99 | "body_form": [], 100 | "raw_body": None, 101 | "previous_step_id": None, 102 | "assertion_type": "DISABLED", 103 | "assertion_value": "", 104 | "is_assert_json_schema_only": False, 105 | "exclude_keys": [], 106 | "status_page_category_id": None, 107 | }, 108 | "execution_time": "2022-09-20T10:00:00+07:00", 109 | "response_time": 100, 110 | "success": False, 111 | "status_code": 500, 112 | "log_response": "Log Response", 113 | "log_error": "", 114 | }, 115 | { 116 | "id": 2, 117 | "monitor": { 118 | "id": 1, 119 | "name": "Test Name", 120 | "method": "GET", 121 | "url": "Test Path", 122 | "schedule": "10MIN", 123 | "body_type": "EMPTY", 124 | "query_params": [], 125 | "headers": [], 126 | "body_form": [], 127 | "raw_body": None, 128 | "previous_step_id": None, 129 | "assertion_type": "DISABLED", 130 | "assertion_value": "", 131 | "is_assert_json_schema_only": False, 132 | "exclude_keys": [], 133 | "status_page_category_id": None, 134 | }, 135 | "execution_time": "2022-09-20T10:00:00+07:00", 136 | "response_time": 75, 137 | "success": False, 138 | "status_code": 500, 139 | "log_response": "", 140 | "log_error": "Log Error", 141 | }, 142 | ]) 143 | 144 | def test_when_authenticated_and_data_are_exists_then_view_error_logs_detail_success(self): 145 | # Create dummy user and authenticate 146 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 147 | team = Team.objects.create(name='test team') 148 | team_member = TeamMember.objects.create(team=team, user=user) 149 | 150 | token = MonAPIToken.objects.create(team_member=team_member) 151 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 152 | 153 | monitor = APIMonitor.objects.create( 154 | team=team, 155 | name='Test Name', 156 | method='GET', 157 | url='Test Path', 158 | schedule='10MIN', 159 | previous_step=None, 160 | body_type='EMPTY', 161 | ) 162 | 163 | APIMonitorResult.objects.create( 164 | monitor=monitor, 165 | execution_time=self.mock_current_time, 166 | response_time=100, 167 | success=False, 168 | status_code=500, 169 | log_response='Log Response', 170 | log_error='Log Error', 171 | ) 172 | 173 | APIMonitorResult.objects.create( 174 | monitor=monitor, 175 | execution_time=self.mock_current_time, 176 | response_time=75, 177 | success=False, 178 | status_code=500, 179 | log_response='', 180 | log_error='Log Error' 181 | ) 182 | 183 | log_id = APIMonitorResult.objects.filter(monitor__team=team, success=False)[:1].get().id 184 | url = reverse('error-logs-detail', kwargs={'pk': log_id}) 185 | response = self.client.get(url, format='json', **header) 186 | self.assertEqual(response.status_code, status.HTTP_200_OK) 187 | self.assertEqual(response.data, 188 | { 189 | "id": 1, 190 | "monitor": { 191 | "id": 1, 192 | "name": "Test Name", 193 | "method": "GET", 194 | "url": "Test Path", 195 | "schedule": "10MIN", 196 | "body_type": "EMPTY", 197 | "query_params": [], 198 | "headers": [], 199 | "body_form": [], 200 | "raw_body": None, 201 | "previous_step_id": None, 202 | "assertion_type": "DISABLED", 203 | "assertion_value": "", 204 | "is_assert_json_schema_only": False, 205 | "exclude_keys": [], 206 | "status_page_category_id": None, 207 | }, 208 | "execution_time": "2022-09-20T10:00:00+07:00", 209 | "response_time": 100, 210 | "status_code": 500, 211 | "success": False, 212 | "log_response": "Log Response", 213 | "log_error": "Log Error", 214 | } 215 | ) 216 | -------------------------------------------------------------------------------- /error_logs/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from error_logs import views 3 | from rest_framework import routers 4 | 5 | router = routers.DefaultRouter() 6 | router.register(r'', views.ErrorLogsViewSet, basename='error-logs') 7 | urlpatterns = [ 8 | path('', include(router.urls)), 9 | ] 10 | -------------------------------------------------------------------------------- /error_logs/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from rest_framework import viewsets, mixins 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.response import Response 5 | 6 | from apimonitor.models import APIMonitorResult 7 | from error_logs.serializers import ErrorLogsSerializer 8 | 9 | class ErrorLogsViewSet(mixins.ListModelMixin, 10 | mixins.RetrieveModelMixin, 11 | viewsets.GenericViewSet): 12 | 13 | queryset = APIMonitorResult.objects.all() 14 | serializer_class = ErrorLogsSerializer 15 | permission_classes = [IsAuthenticated] 16 | 17 | def retrieve(self, request, pk=None): 18 | queryset = APIMonitorResult.objects.filter(monitor__team=self.request.auth.team, success=False) 19 | obj = get_object_or_404(queryset, id=pk) 20 | serializer = self.get_serializer(obj) 21 | return Response(serializer.data) 22 | 23 | def get_queryset(self): 24 | limit = 1500 25 | queryset = APIMonitorResult.objects.filter(monitor__team=self.request.auth.team, success=False).order_by("-execution_time")[:limit:1] 26 | return queryset 27 | -------------------------------------------------------------------------------- /forget_password/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/forget_password/__init__.py -------------------------------------------------------------------------------- /forget_password/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from forget_password.models import ForgetPasswordToken 3 | 4 | admin.site.register(ForgetPasswordToken) 5 | -------------------------------------------------------------------------------- /forget_password/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ForgetPasswordConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'forget_password' 7 | -------------------------------------------------------------------------------- /forget_password/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-10-31 19:00 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='ForgetPasswordToken', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('key', models.CharField(default=uuid.uuid4, max_length=256)), 23 | ('created_at', models.DateTimeField(auto_now_add=True)), 24 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /forget_password/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/forget_password/migrations/__init__.py -------------------------------------------------------------------------------- /forget_password/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | from django.contrib.auth.models import User 4 | 5 | class ForgetPasswordToken(models.Model): 6 | user = models.ForeignKey(User, on_delete=models.CASCADE) 7 | key = models.CharField(max_length=256, default=uuid.uuid4) 8 | created_at = models.DateTimeField(auto_now_add=True) 9 | -------------------------------------------------------------------------------- /forget_password/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from django.contrib.auth.password_validation import validate_password 3 | from django.core.exceptions import ValidationError 4 | 5 | 6 | class RequestForgetPasswordTokenSerializer(serializers.Serializer): 7 | email = serializers.EmailField() 8 | 9 | 10 | class ForgetPasswordTokenCheckSerializer(serializers.Serializer): 11 | key = serializers.CharField(max_length=256) 12 | 13 | 14 | class ResetPasswordSerializer(serializers.Serializer): 15 | password = serializers.CharField(max_length=128) 16 | key = serializers.CharField(max_length=256) 17 | 18 | def validate_password(self, password): 19 | try: 20 | validate_password(password) 21 | except ValidationError as exc: 22 | raise exc 23 | return password 24 | -------------------------------------------------------------------------------- /forget_password/tests.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | 3 | from django.urls import reverse 4 | from django.conf import settings 5 | from django.contrib.auth.models import User 6 | from django.utils import timezone 7 | from rest_framework import status 8 | from rest_framework.test import APITestCase 9 | from rest_framework.authtoken.models import Token 10 | from datetime import datetime 11 | from django.core.mail import send_mail 12 | from unittest.mock import patch 13 | 14 | from forget_password.models import ForgetPasswordToken 15 | 16 | 17 | def mocked_send_email(*args, **kwargs): 18 | pass 19 | 20 | class RequestForgetPasswordToken(APITestCase): 21 | test_url = reverse('reset-password-token') 22 | local_timezone = pytz.timezone(settings.TIME_ZONE) 23 | mock_current_time = local_timezone.localize(datetime(2022, 9, 20, 10)) 24 | 25 | def setUp(self): 26 | # Mock time function 27 | timezone.now = lambda: self.mock_current_time 28 | 29 | def test_check_token_if_key_not_exists_then_return_error(self): 30 | response = self.client.get(self.test_url, format='json') 31 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 32 | self.assertEqual(response.data, {"key": ["This field is required."]}) 33 | 34 | def test_check_token_if_key_exists_but_token_invalid_then_return_error(self): 35 | response = self.client.get(self.test_url, { 36 | 'key': 'abc' 37 | }, format='json') 38 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 39 | self.assertEqual(response.data, {'error': 'Invalid token'}) 40 | 41 | def test_check_token_if_key_exists_and_token_valid_then_return_success(self): 42 | user = User.objects.create_user(username='test', email='test@test.com', password='test123') 43 | token = ForgetPasswordToken.objects.create(user=user) 44 | response = self.client.get(self.test_url, { 45 | 'key': token.key 46 | }, format='json') 47 | self.assertEqual(response.status_code, status.HTTP_200_OK) 48 | self.assertEqual(response.data, {'success': True}) 49 | 50 | def test_request_token_if_parameter_not_exists_then_return_error(self): 51 | response = self.client.post(self.test_url, format='json') 52 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 53 | self.assertEqual(response.data, {"email": ["This field is required."]}) 54 | 55 | def test_request_token_if_user_not_exists_then_return_error(self): 56 | response = self.client.post(self.test_url, { 57 | 'email': 'invalidmail@admin.com', 58 | }, format='json') 59 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 60 | self.assertEqual(response.data, {'error': 'User not exists with given email'}) 61 | 62 | @patch("django.core.mail.send_mail", mocked_send_email) 63 | def test_request_token_if_user_exists_then_return_success(self): 64 | user = User.objects.create_user(username='test', email='test@test.com', password='test123') 65 | response = self.client.post(self.test_url, { 66 | 'email': 'test@test.com', 67 | }, format='json') 68 | self.assertEqual(response.status_code, status.HTTP_200_OK) 69 | self.assertEqual(response.data, {'success': True}) 70 | 71 | 72 | class ResetPasswordTest(APITestCase): 73 | test_url = reverse('reset-password') 74 | local_timezone = pytz.timezone(settings.TIME_ZONE) 75 | mock_current_time = local_timezone.localize(datetime(2022, 9, 20, 10)) 76 | 77 | def setUp(self): 78 | # Mock time function 79 | timezone.now = lambda: self.mock_current_time 80 | 81 | def test_if_key_not_exists_then_return_error(self): 82 | response = self.client.post(self.test_url, format='json') 83 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 84 | self.assertEqual(response.data, {"password": ["This field is required."], "key": ["This field is required."]}) 85 | 86 | def test_if_key_exists_but_token_invalid_then_return_error(self): 87 | response = self.client.post(self.test_url, { 88 | 'key': 'abc', 89 | 'password': 'Test123486827', 90 | }, format='json') 91 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 92 | self.assertEqual(response.data, {'error': 'Invalid token'}) 93 | 94 | def test_if_key_exists_and_token_valid__and_pasword_same_as_old_then_return_error(self): 95 | user = User.objects.create_user(username='test', email='test@test.com', password='Test123486827') 96 | token = ForgetPasswordToken.objects.create(user=user) 97 | response = self.client.post(self.test_url, { 98 | 'key': token.key, 99 | 'password': 'Test123486827', 100 | }, format='json') 101 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 102 | self.assertEqual(response.data, {'error': 'Please choose password that different from your current password'}) 103 | 104 | def test_if_key_exists_token_valid_but_invalid_password_then_return_error(self): 105 | user = User.objects.create_user(username='test', email='test@test.com', password='Test123486827') 106 | token = ForgetPasswordToken.objects.create(user=user) 107 | response = self.client.post(self.test_url, { 108 | 'key': token.key, 109 | 'password': 'abc', 110 | }, format='json') 111 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 112 | self.assertEqual(response.data, {"password": [ 113 | "This password is too short. It must contain at least 8 characters.", 114 | "This password is too common.", 115 | "The password must contain at least 1 digit, 0-9.", 116 | "The password must contain at least 1 uppercase letter, A-Z." 117 | ]}) 118 | 119 | def test_if_key_exists_and_token_valid_then_return_success(self): 120 | user = User.objects.create_user(username='test', email='test@test.com', password='Test123486827') 121 | token = ForgetPasswordToken.objects.create(user=user) 122 | response = self.client.post(self.test_url, { 123 | 'key': token.key, 124 | 'password': 'Test1234868271', 125 | }, format='json') 126 | self.assertEqual(response.status_code, status.HTTP_200_OK) 127 | self.assertEqual(response.data, {'success': True}) 128 | 129 | user = User.objects.get(email='test@test.com') 130 | self.assertTrue(user.check_password('Test1234868271')) 131 | 132 | -------------------------------------------------------------------------------- /forget_password/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from forget_password.views import RequestForgetPasswordTokenView, ResetPasswordView 4 | 5 | 6 | urlpatterns = [ 7 | path('token/', RequestForgetPasswordTokenView.as_view(), name='reset-password-token'), 8 | path('change/', ResetPasswordView.as_view(), name='reset-password'), 9 | ] 10 | -------------------------------------------------------------------------------- /forget_password/views.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from django.contrib.auth.models import User 3 | from django.core.mail import send_mail 4 | from django.template.loader import render_to_string 5 | from django.utils import timezone 6 | from rest_framework import views, status 7 | from rest_framework.response import Response 8 | 9 | from forget_password.models import ForgetPasswordToken 10 | from forget_password.serializers import RequestForgetPasswordTokenSerializer, ResetPasswordSerializer, ForgetPasswordTokenCheckSerializer 11 | 12 | import os 13 | 14 | 15 | class RequestForgetPasswordTokenView(views.APIView): 16 | authentication_classes = [] 17 | permission_classes = [] 18 | 19 | def get(self, request, format=None): 20 | serializer = ForgetPasswordTokenCheckSerializer(data=request.query_params) 21 | if serializer.is_valid(): 22 | tokens = ForgetPasswordToken.objects.filter( 23 | key=serializer.validated_data['key'], 24 | created_at__gte=timezone.now() - timedelta(hours=1) 25 | ) 26 | if len(tokens) == 0: 27 | return Response({'error': 'Invalid token'}, status=status.HTTP_400_BAD_REQUEST) 28 | return Response({'success': True}) 29 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 30 | 31 | def post(self, request, format=None): 32 | serializer = RequestForgetPasswordTokenSerializer(data=request.data) 33 | if serializer.is_valid(): 34 | users = User.objects.filter(email=serializer.validated_data['email']) 35 | if len(users) == 0: 36 | return Response({'error': 'User not exists with given email'}, status=status.HTTP_400_BAD_REQUEST) 37 | 38 | user = users[0] 39 | token = ForgetPasswordToken.objects.create(user=user) 40 | 41 | reset_url = f"{os.environ.get('FRONTEND_URL', '')}/forget_password?key={token.key}" 42 | 43 | email_content = f''' 44 | We have received a password reset request for your email.\n 45 | Please follow below link to create new password for your account. \n\n 46 | {reset_url} 47 | This link is valid for 1 hours. 48 | ''' 49 | 50 | email_content_html = render_to_string('email/reset_password.html', { 51 | 'reset_url': reset_url, 52 | }) 53 | 54 | send_mail( 55 | 'MonAPI Reset Password Request', 56 | email_content, 57 | None, 58 | [user.email], 59 | html_message=email_content_html, 60 | fail_silently=False, 61 | ) 62 | 63 | return Response({'success': True}) 64 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 65 | 66 | 67 | class ResetPasswordView(views.APIView): 68 | authentication_classes = [] 69 | permission_classes = [] 70 | 71 | def post(self, request, format=None): 72 | serializer = ResetPasswordSerializer(data=request.data) 73 | if serializer.is_valid(): 74 | tokens = ForgetPasswordToken.objects.filter( 75 | key=serializer.validated_data['key'], 76 | created_at__gte=timezone.now() - timedelta(hours=1) 77 | ) 78 | if len(tokens) == 0: 79 | return Response({'error': 'Invalid token'}, status=status.HTTP_400_BAD_REQUEST) 80 | 81 | token = tokens[0] 82 | user = token.user 83 | 84 | check_password = user.check_password(serializer.validated_data['password']) 85 | if check_password: 86 | return Response({'error': 'Please choose password that different from your current password'}, status=status.HTTP_400_BAD_REQUEST) 87 | 88 | user.set_password(serializer.validated_data['password']) 89 | user.save() 90 | 91 | token.delete() 92 | 93 | return Response({'success': True}) 94 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 95 | -------------------------------------------------------------------------------- /invite_team_members/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/invite_team_members/__init__.py -------------------------------------------------------------------------------- /invite_team_members/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from invite_team_members.models import InviteTeamMemberToken 3 | 4 | admin.site.register(InviteTeamMemberToken) 5 | -------------------------------------------------------------------------------- /invite_team_members/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class InviteTeamMembersConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'invite_team_members' 7 | -------------------------------------------------------------------------------- /invite_team_members/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-19 17:58 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('login', '0003_teammember_verified'), 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='InviteTeamMemberToken', 21 | fields=[ 22 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('key', models.CharField(default=uuid.uuid4, max_length=256)), 24 | ('created_at', models.DateTimeField(auto_now_add=True)), 25 | ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='login.team')), 26 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /invite_team_members/migrations/0002_remove_inviteteammembertoken_team_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-20 15:31 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('login', '0003_teammember_verified'), 11 | ('invite_team_members', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='inviteteammembertoken', 17 | name='team', 18 | ), 19 | migrations.RemoveField( 20 | model_name='inviteteammembertoken', 21 | name='user', 22 | ), 23 | migrations.AddField( 24 | model_name='inviteteammembertoken', 25 | name='team_member', 26 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='login.teammember'), 27 | preserve_default=False, 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /invite_team_members/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/invite_team_members/migrations/__init__.py -------------------------------------------------------------------------------- /invite_team_members/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | from django.contrib.auth.models import User 4 | from login.models import TeamMember 5 | 6 | class InviteTeamMemberToken(models.Model): 7 | team_member = models.ForeignKey(TeamMember, on_delete=models.CASCADE) 8 | key = models.CharField(max_length=256, default=uuid.uuid4) 9 | created_at = models.DateTimeField(auto_now_add=True) 10 | -------------------------------------------------------------------------------- /invite_team_members/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | 4 | class InviteTeamMemberTokenCheckSerializer(serializers.Serializer): 5 | key = serializers.CharField(max_length=256) 6 | 7 | 8 | class RequestInviteTeamMemberTokenSerializer(serializers.Serializer): 9 | invited_email = serializers.EmailField() 10 | 11 | 12 | class AcceptInviteSerializer(serializers.Serializer): 13 | key = serializers.CharField(max_length=256) 14 | 15 | 16 | class CancelInviteSerializer(serializers.Serializer): 17 | user_id = serializers.IntegerField() 18 | -------------------------------------------------------------------------------- /invite_team_members/tests.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | 3 | from django.urls import reverse 4 | from django.test import TestCase 5 | from django.contrib.auth.models import User 6 | from django.conf import settings 7 | from django.utils import timezone 8 | from datetime import datetime 9 | from rest_framework import status 10 | from rest_framework.test import APITestCase 11 | from unittest.mock import patch 12 | 13 | from invite_team_members.models import InviteTeamMemberToken 14 | from login.models import Team, TeamMember, MonAPIToken 15 | 16 | def mocked_send_email(*args, **kwargs): 17 | pass 18 | 19 | class InviteTeamMemberTokenTest(TestCase): 20 | local_timezone = pytz.timezone(settings.TIME_ZONE) 21 | mock_current_time = local_timezone.localize(datetime(2022, 9, 20, 10)) 22 | 23 | def setUp(self): 24 | timezone.now = lambda: self.mock_current_time 25 | 26 | def test_when_created_key_is_made(self): 27 | user = User.objects.create_user(username='test@gmail.com', email='test@gmail.com', password='Test1234') 28 | team = Team.objects.create(name="default team") 29 | team_member = TeamMember.objects.create(user=user, team=team) 30 | inviteToken = InviteTeamMemberToken.objects.create(team_member=team_member) 31 | self.assertEqual(InviteTeamMemberToken.objects.all().count(), 1) 32 | 33 | class RequestInviteTeamMemberTokenViewTest(APITestCase): 34 | test_url = reverse('invite-member-token') 35 | local_timezone = pytz.timezone(settings.TIME_ZONE) 36 | mock_current_time = local_timezone.localize(datetime(2022, 9, 20, 10)) 37 | 38 | def setUp(self): 39 | timezone.now = lambda: self.mock_current_time 40 | self.default_user = User.objects.create_user(username='default user', email='test@gmail.com', password='test1234') 41 | self.default_team = Team.objects.create(name='default team') 42 | self.default_team_member = TeamMember.objects.create(team=self.default_team, user=self.default_user, verified=True) 43 | self.default_token = MonAPIToken.objects.create(team_member=self.default_team_member) 44 | self.default_header = {'HTTP_AUTHORIZATION': f"Token {self.default_token.key}"} 45 | self.default_invite_token = InviteTeamMemberToken.objects.create(team_member=self.default_team_member) 46 | 47 | def test_check_user_must_be_authenthicated_to_get_invite_token(self): 48 | response = self.client.get(self.test_url, format='json', **self.default_header) 49 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 50 | self.assertEqual(response.data, {"key": ["This field is required."]}) 51 | 52 | def test_check_if_token_is_given_but_invalid(self): 53 | response = self.client.get(self.test_url, { 54 | 'key': 'invalid' 55 | }, format='json', **self.default_header) 56 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 57 | self.assertEqual(response.data, {'error': 'Invalid token'}) 58 | 59 | def test_check_token_exist_and_is_valid(self): 60 | response = self.client.get(self.test_url, { 61 | 'key': self.default_invite_token.key 62 | }, format='json', **self.default_header) 63 | self.assertEqual(response.status_code, status.HTTP_200_OK) 64 | self.assertEqual(response.data, {'success': True}) 65 | 66 | def test_request_invite_token_but_no_param(self): 67 | response = self.client.post(self.test_url, format='json', **self.default_header) 68 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 69 | self.assertEqual(len(response.data), 1) 70 | 71 | def test_request_invite_token_but_invited_user_does_not_exist(self): 72 | response = self.client.post(self.test_url, { 73 | 'invited_email': 'invalid@gmail.com', 74 | }, format='json', **self.default_header) 75 | self.assertEqual(response.data, {'error': 'User not exists with given email'}) 76 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 77 | 78 | def test_request_invite_token_and_everything_valid_but_user_is_already_in_team(self): 79 | already_verified = User.objects.create_user(username='new', email='new@gmail.com', password='new12345') 80 | team_member = TeamMember.objects.create(user=already_verified, team=self.default_team, verified=False) 81 | InviteTeamMemberToken.objects.create(team_member=team_member) 82 | response = self.client.post(self.test_url, { 83 | 'invited_email': already_verified.email, 84 | }, format='json', **self.default_header) 85 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 86 | self.assertEqual(response.data, {'error': 'User is already in the process of being invited to the team'}) 87 | 88 | @patch("django.core.mail.send_mail", mocked_send_email) 89 | def test_request_invite_token_and_everything_valid_but_user_is_invited_twice(self): 90 | invited_user = User.objects.create_user(username='new user', email='new_user@gmail.com', password='test1234') 91 | response = self.client.post(self.test_url, { 92 | 'invited_email': invited_user.email, 93 | }, format='json', **self.default_header) 94 | # User is invited and is added to the team as unverified user 95 | self.assertEqual(response.status_code, status.HTTP_200_OK) 96 | self.assertEqual(response.data, {'success': True}) 97 | self.assertEqual(TeamMember.objects.filter(team=self.default_team).count(), 2) 98 | self.assertEqual(TeamMember.objects.get(user=invited_user, team=self.default_team).verified, False) 99 | 100 | # User is invited again 101 | response = self.client.post(self.test_url, { 102 | 'invited_email': invited_user.email, 103 | }, format='json', **self.default_header) 104 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 105 | self.assertEqual(response.data, {'error': 'User is already in the process of being invited to the team'}) 106 | self.assertEqual(TeamMember.objects.filter(team=self.default_team).count(), 2) 107 | 108 | def test_request_invite_to_an_already_verified_team_member(self): 109 | response = self.client.post(self.test_url, { 110 | 'invited_email': self.default_user.email, 111 | }, format='json', **self.default_header) 112 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 113 | self.assertEqual(response.data, {'error': 'User is already in the team'}) 114 | 115 | 116 | class AcceptInviteViewTest(APITestCase): 117 | test_url = reverse('accept-invite') 118 | local_timezone = pytz.timezone(settings.TIME_ZONE) 119 | mock_current_time = local_timezone.localize(datetime(2022, 9, 20, 10)) 120 | 121 | def setUp(self): 122 | timezone.now = lambda: self.mock_current_time 123 | self.default_user = User.objects.create_user(username='default user', email='test@gmail.com', 124 | password='test1234') 125 | self.default_team = Team.objects.create(name='default team') 126 | self.default_team_member = TeamMember.objects.create(team=self.default_team, user=self.default_user, verified=True) 127 | self.default_token = MonAPIToken.objects.create(team_member=self.default_team_member) 128 | self.default_header = {'HTTP_AUTHORIZATION': f"Token {self.default_token.key}"} 129 | self.default_invite_token = InviteTeamMemberToken.objects.create(team_member=self.default_team_member) 130 | 131 | def test_frontend_sends_no_data(self): 132 | response = self.client.post(self.test_url, {}, format='json') 133 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 134 | self.assertEqual(response.data, {"key": ["This field is required."]}) 135 | 136 | def test_invite_already_verified_user(self): 137 | response = self.client.post(self.test_url, { 138 | 'key': self.default_invite_token.key 139 | }, format='json') 140 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 141 | self.assertEqual(response.data, {'error': 'You are already a member of the team'}) 142 | 143 | @patch("django.core.mail.send_mail", mocked_send_email) 144 | def test_frontend_sends_unused_token_and_try_reusing_same_token(self): 145 | invited_user = User.objects.create_user(username='new user', email='new_user@gmail.com', password='test1234') 146 | response = self.client.post(reverse('invite-member-token'), { 147 | 'invited_email': invited_user.email, 148 | }, format='json', **self.default_header) 149 | # User is invited and is added to the team as unverified user 150 | self.assertEqual(response.status_code, status.HTTP_200_OK) 151 | self.assertEqual(response.data, {'success': True}) 152 | self.assertEqual(TeamMember.objects.filter(team=self.default_team).count(), 2) 153 | 154 | team_member = TeamMember.objects.get(user=invited_user, team=self.default_team) 155 | invite_token = InviteTeamMemberToken.objects.get(team_member=team_member) 156 | invite_token_key = invite_token.key 157 | response = self.client.post(self.test_url, { 158 | 'key': invite_token_key 159 | }, format='json', **self.default_header) 160 | self.assertEqual(response.status_code, status.HTTP_200_OK) 161 | self.assertEqual(response.data, {'success': True}) 162 | self.assertEqual(InviteTeamMemberToken.objects.all().count(), 1) 163 | 164 | # Reusing same token 165 | invite_token_key = invite_token.key 166 | response = self.client.post(self.test_url, { 167 | 'key': invite_token_key 168 | }, format='json') 169 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 170 | self.assertEqual(response.data, {'error': 'Invalid token'}) 171 | self.assertEqual(InviteTeamMemberToken.objects.all().count(), 1) 172 | 173 | 174 | class CancelInviteViewTest(APITestCase): 175 | test_url = reverse('cancel-invite') 176 | local_timezone = pytz.timezone(settings.TIME_ZONE) 177 | mock_current_time = local_timezone.localize(datetime(2022, 9, 20, 10)) 178 | 179 | def setUp(self): 180 | timezone.now = lambda: self.mock_current_time 181 | self.default_user = User.objects.create_user(username='default user', email='test@gmail.com', 182 | password='test1234') 183 | self.default_team = Team.objects.create(name='default team') 184 | self.default_team_member = TeamMember.objects.create(team=self.default_team, user=self.default_user, verified=True) 185 | self.default_token = MonAPIToken.objects.create(team_member=self.default_team_member) 186 | self.default_header = {'HTTP_AUTHORIZATION': f"Token {self.default_token.key}"} 187 | self.default_invite_token = InviteTeamMemberToken.objects.create(team_member=self.default_team_member) 188 | 189 | def test_frontend_send_no_data(self): 190 | response = self.client.post(self.test_url, format='json', **self.default_header) 191 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 192 | self.assertEqual(response.data, { 193 | "user_id": ["This field is required."] 194 | }) 195 | 196 | def test_frontend_sends_invalid_user_id(self): 197 | response = self.client.post(self.test_url, { 198 | 'user_id': self.default_user.id, 199 | }, format='json', **self.default_header) 200 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 201 | self.assertEqual(response.data, {'error': 'Invalid user id'}) 202 | 203 | @patch("django.core.mail.send_mail", mocked_send_email) 204 | def test_request_invite_token_and_then_cancel(self): 205 | invited_user = User.objects.create_user(username='new user', email='new_user@gmail.com', password='test1234') 206 | self.client.post(reverse('invite-member-token'), { 207 | 'invited_email': invited_user.email, 208 | }, format='json', **self.default_header) 209 | 210 | response = self.client.post(self.test_url, { 211 | 'user_id': invited_user.id, 212 | }, format='json', **self.default_header) 213 | 214 | self.assertEqual(response.status_code, status.HTTP_200_OK) 215 | self.assertEqual(response.data, {'success': True}) 216 | self.assertEqual(TeamMember.objects.count(), 1) 217 | self.assertEqual(InviteTeamMemberToken.objects.count(), 1) 218 | 219 | def test_unverified_member_cannot_cancel_invite(self): 220 | other_team = Team.objects.create(name='another team') 221 | other_user = User.objects.create_user(username="other", email="other@gmail.com", password="other123") 222 | other_team_member = TeamMember.objects.create(user=other_user, team=other_team) 223 | invite_token = InviteTeamMemberToken.objects.create(team_member=other_team_member) 224 | 225 | response = self.client.post(self.test_url, { 226 | 'user_id': other_user.id 227 | }, format='json', **self.default_header) 228 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 229 | self.assertEqual(response.data, {'error': 'Invalid user id'}) 230 | -------------------------------------------------------------------------------- /invite_team_members/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from invite_team_members.views import RequestInviteTeamMemberTokenView, AcceptInviteView, CancelInviteView 4 | 5 | urlpatterns=[ 6 | path('token/', RequestInviteTeamMemberTokenView.as_view(), name='invite-member-token'), 7 | path('accept/', AcceptInviteView.as_view(), name='accept-invite'), 8 | path('cancel/', CancelInviteView.as_view(), name='cancel-invite'), 9 | ] -------------------------------------------------------------------------------- /invite_team_members/views.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from django.contrib.auth.models import User 3 | from django.core.mail import send_mail 4 | from django.template.loader import render_to_string 5 | from django.utils import timezone 6 | from rest_framework import views, status 7 | from rest_framework.response import Response 8 | 9 | from invite_team_members.models import InviteTeamMemberToken 10 | from invite_team_members.serializers import (InviteTeamMemberTokenCheckSerializer, 11 | RequestInviteTeamMemberTokenSerializer, 12 | AcceptInviteSerializer, 13 | CancelInviteSerializer) 14 | from login.models import Team, TeamMember 15 | 16 | import os 17 | 18 | 19 | class RequestInviteTeamMemberTokenView(views.APIView): 20 | 21 | def get(self, request, format=None): 22 | serializer = InviteTeamMemberTokenCheckSerializer(data=request.query_params) 23 | if serializer.is_valid(): 24 | token = InviteTeamMemberToken.objects.filter( 25 | key=serializer.validated_data['key'], 26 | created_at__gte=timezone.now() - timedelta(weeks=1) 27 | ) 28 | if len(token) == 0: 29 | return Response({'error': 'Invalid token'}, status=status.HTTP_400_BAD_REQUEST) 30 | return Response({'success': True}) 31 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 32 | 33 | def post(self, request, format=None): 34 | serializer = RequestInviteTeamMemberTokenSerializer(data=request.data) 35 | if serializer.is_valid(): 36 | users = User.objects.filter(email=serializer.validated_data['invited_email']) 37 | if len(users) == 0: 38 | return Response({'error': 'User not exists with given email'}, status=status.HTTP_400_BAD_REQUEST) 39 | 40 | user = users[0] 41 | team = request.auth.team 42 | 43 | team_member = TeamMember.objects.filter(team=team, user=user, verified=True) 44 | if team_member.exists(): 45 | return Response({'error': 'User is already in the team'}, status=status.HTTP_400_BAD_REQUEST) 46 | 47 | token = InviteTeamMemberToken.objects.filter( 48 | team_member__user=user, 49 | team_member__team=team, 50 | created_at__gte=timezone.now() - timedelta(weeks=1) 51 | ) 52 | if token.exists(): 53 | return Response({'error': 'User is already in the process of being invited to the team'}, 54 | status=status.HTTP_400_BAD_REQUEST) 55 | 56 | team_member, _ = TeamMember.objects.get_or_create(user=user, team=team, verified=False) 57 | invite_token = InviteTeamMemberToken.objects.create(team_member=team_member) 58 | accept_url = f"{os.environ.get('FRONTEND_URL', '')}/invite-member?key={invite_token.key}" 59 | email_content = f''' 60 | You have been invited to join Team {team.name}.\n 61 | Please follow below link to accept the invitation and join the team. \n\n 62 | {accept_url} 63 | This link is valid for 1 week. 64 | ''' 65 | 66 | email_content_html = render_to_string('email/accept_invite.html', { 67 | 'team_name': team.name, 68 | 'accept_url': accept_url, 69 | }) 70 | 71 | send_mail( 72 | f'MonAPI Invitation to Team {team.name}', 73 | email_content, 74 | None, 75 | [user.email], 76 | html_message=email_content_html, 77 | fail_silently=False, 78 | ) 79 | 80 | return Response({'success': True}) 81 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 82 | 83 | 84 | class AcceptInviteView(views.APIView): 85 | authentication_classes = [] 86 | permission_classes = [] 87 | def post(self, request, format=None): 88 | serializer = AcceptInviteSerializer(data=request.data) 89 | if serializer.is_valid(): 90 | invite_token = InviteTeamMemberToken.objects.filter( 91 | key=serializer.validated_data['key'], 92 | created_at__gte=timezone.now() - timedelta(weeks=1) 93 | ) 94 | if len(invite_token) == 0: 95 | return Response({'error': 'Invalid token'}, status=status.HTTP_400_BAD_REQUEST) 96 | invite_token = invite_token[0] 97 | 98 | team_member = invite_token.team_member 99 | if team_member.verified: 100 | return Response({'error': 'You are already a member of the team'}, 101 | status=status.HTTP_400_BAD_REQUEST) 102 | 103 | team_member.verified = True 104 | team_member.save() 105 | invite_token.delete() 106 | return Response({'success': True}) 107 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 108 | 109 | 110 | class CancelInviteView(views.APIView): 111 | def post(self, request, format=None): 112 | serializer = CancelInviteSerializer(data=request.data) 113 | if serializer.is_valid(): 114 | user = User.objects.get(pk=serializer.validated_data['user_id']) 115 | team = request.auth.team 116 | 117 | team_member = TeamMember.objects.filter( 118 | team=team, 119 | user=user, 120 | verified=False 121 | ) 122 | 123 | if not team_member.exists(): 124 | return Response({'error': 'Invalid user id'}, status=status.HTTP_400_BAD_REQUEST) 125 | 126 | team_member = team_member[0] 127 | 128 | invite_token = InviteTeamMemberToken.objects.filter( 129 | team_member=team_member, 130 | ) 131 | 132 | invite_token.delete() 133 | team_member.delete() 134 | return Response({'success': True}) 135 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 136 | 137 | -------------------------------------------------------------------------------- /login/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/login/__init__.py -------------------------------------------------------------------------------- /login/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from login.models import MonAPIToken, Team, TeamMember 3 | 4 | admin.site.register(Team) 5 | admin.site.register(TeamMember) 6 | admin.site.register(MonAPIToken) 7 | -------------------------------------------------------------------------------- /login/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class LoginConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'login' 7 | -------------------------------------------------------------------------------- /login/auth.py: -------------------------------------------------------------------------------- 1 | from rest_framework.authentication import TokenAuthentication 2 | from login.models import MonAPIToken 3 | from rest_framework import exceptions 4 | 5 | class MonAPITokenAuthentication(TokenAuthentication): 6 | model = MonAPIToken 7 | 8 | def authenticate_credentials(self, key): 9 | model = self.get_model() 10 | try: 11 | token = model.objects.select_related('team_member__user').get(key=key) 12 | except model.DoesNotExist: 13 | raise exceptions.AuthenticationFailed('Invalid token.') 14 | 15 | if not token.team_member.user.is_active: 16 | raise exceptions.AuthenticationFailed('User inactive or deleted.') 17 | 18 | return (token.team_member.user, token) -------------------------------------------------------------------------------- /login/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-15 07:40 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import login.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Team', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('name', models.CharField(max_length=128)), 23 | ('description', models.TextField()), 24 | ('logo', models.FileField(unique=True, upload_to=login.models.get_file_path)), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='TeamMember', 29 | fields=[ 30 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teammember', to='login.team')), 32 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 33 | ], 34 | options={ 35 | 'unique_together': {('team', 'user')}, 36 | }, 37 | ), 38 | migrations.CreateModel( 39 | name='MonAPIToken', 40 | fields=[ 41 | ('key', models.CharField(max_length=40, primary_key=True, serialize=False)), 42 | ('created', models.DateTimeField(auto_now_add=True)), 43 | ('team_member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='login.teammember')), 44 | ], 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /login/migrations/0002_alter_team_description_alter_team_logo.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-15 07:46 2 | 3 | from django.db import migrations, models 4 | import login.models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('login', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='team', 16 | name='description', 17 | field=models.TextField(blank=True), 18 | ), 19 | migrations.AlterField( 20 | model_name='team', 21 | name='logo', 22 | field=models.FileField(blank=True, null=True, upload_to=login.models.get_file_path), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /login/migrations/0003_teammember_verified.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-18 10:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('login', '0002_alter_team_description_alter_team_logo'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='teammember', 15 | name='verified', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /login/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/login/migrations/__init__.py -------------------------------------------------------------------------------- /login/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | 4 | import binascii 5 | import os 6 | import uuid 7 | 8 | def get_file_path(_, filename): 9 | ext = filename.split('.')[-1] 10 | filename = "%s.%s" % (uuid.uuid4(), ext) 11 | return os.path.join('teamlogo/', filename) 12 | 13 | class Team(models.Model): 14 | name = models.CharField(max_length=128) 15 | description = models.TextField(blank=True) 16 | logo = models.FileField(upload_to=get_file_path, null=True, blank=True) 17 | 18 | 19 | class TeamMember(models.Model): 20 | team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='teammember') 21 | user = models.ForeignKey(User, on_delete=models.CASCADE) 22 | verified = models.BooleanField(default=False) 23 | 24 | class Meta: 25 | unique_together = ('team', 'user',) 26 | 27 | 28 | class MonAPIToken(models.Model): 29 | key = models.CharField(max_length=40, primary_key=True) 30 | team_member = models.ForeignKey(TeamMember, on_delete=models.CASCADE) 31 | created = models.DateTimeField(auto_now_add=True) 32 | 33 | def save(self, *args, **kwargs): 34 | if not self.key: 35 | self.key = self.generate_key() 36 | return super().save(*args, **kwargs) 37 | 38 | @property 39 | def team(self): 40 | return self.team_member.team 41 | 42 | @classmethod 43 | def generate_key(cls): 44 | return binascii.hexlify(os.urandom(20)).decode() 45 | 46 | def __str__(self): 47 | return self.key 48 | -------------------------------------------------------------------------------- /login/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from login.models import Team, TeamMember 3 | 4 | class LoginSerializer(serializers.Serializer): 5 | email = serializers.EmailField() 6 | password = serializers.CharField() 7 | 8 | def validate_attribute(self, attrs): 9 | email = attrs.get('email') 10 | password = attrs.get('password') 11 | 12 | response = "" 13 | if not email and not password: 14 | response = 'Both email and password are required.' 15 | raise serializers.ValidationError({'response': response}, code='authorization') 16 | elif not email: 17 | response = 'Email is required.' 18 | raise serializers.ValidationError({'response': response}, code='authorization') 19 | elif not password: 20 | response = 'Password is required.' 21 | raise serializers.ValidationError({'response': response}, code='authorization') 22 | 23 | 24 | class TeamSerializers(serializers.ModelSerializer): 25 | class Meta: 26 | model = Team 27 | fields = [ 28 | 'id', 29 | 'name', 30 | ] 31 | -------------------------------------------------------------------------------- /login/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from login import views 3 | 4 | urlpatterns = [ 5 | path('', views.user_login, name='login'), 6 | 7 | # New authentication path 8 | path('login/', views.user_login, name='auth-login'), 9 | path('logout/', views.user_logout, name='auth-logout'), 10 | path('current_team/', views.current_team, name='auth-current-team'), 11 | path('available_team/', views.available_team, name='auth-available-team'), 12 | path('change_team/', views.change_team, name='auth-change-team'), 13 | ] -------------------------------------------------------------------------------- /login/utils.py: -------------------------------------------------------------------------------- 1 | from login.models import MonAPIToken, TeamMember, Team 2 | 3 | def generate_token(user): 4 | team_member_query = TeamMember.objects.filter(user=user, verified=True) 5 | 6 | if not team_member_query.exists(): 7 | username = user.username.split('@')[0] 8 | team = Team.objects.create(name=username.title()) 9 | team_member = TeamMember.objects.create(user=user, team=team, verified=True) 10 | else: 11 | team_member = team_member_query.first() 12 | 13 | token = MonAPIToken.objects.create(team_member=team_member) 14 | return token.key -------------------------------------------------------------------------------- /login/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.decorators import api_view, authentication_classes, permission_classes 2 | from rest_framework.response import Response 3 | from rest_framework.permissions import IsAuthenticated 4 | from django.contrib.auth import authenticate, logout 5 | from rest_framework import status 6 | 7 | from login.serializers import LoginSerializer, TeamSerializers 8 | from login.models import TeamMember, Team 9 | from login.utils import generate_token 10 | from register.models import VerifiedUser 11 | from django.core.exceptions import ObjectDoesNotExist 12 | 13 | @api_view(["POST"]) 14 | @authentication_classes([]) 15 | @permission_classes([]) 16 | def user_login(request): 17 | data = {} 18 | serializer = LoginSerializer(data=request.data) 19 | 20 | serializer.validate_attribute(request.data) 21 | user = authenticate(request=request, 22 | username=request.data['email'], 23 | email=request.data['email'], 24 | password=request.data['password']) 25 | 26 | if not user: 27 | data['response'] = 'Invalid email or password.' 28 | return Response(data=data, status=status.HTTP_401_UNAUTHORIZED) 29 | 30 | # Filter user based on VerifiedUser 31 | try: 32 | is_verified = VerifiedUser.objects.get(user=user).verified 33 | except ObjectDoesNotExist: 34 | # Legacy Support: Users that already create their account 35 | # before this update will automatically be verified 36 | VerifiedUser.objects.create(user=user, verified=True) 37 | is_verified = True 38 | 39 | if not is_verified: 40 | data['response'] = 'User not yet verified.' 41 | return Response(data=data, status=status.HTTP_401_UNAUTHORIZED) 42 | 43 | data['response'] = 'Sign-in successful.' 44 | data['email']= user.email 45 | data['token']= generate_token(user) 46 | 47 | return Response(data=data, status=status.HTTP_200_OK) 48 | 49 | 50 | @api_view(["POST"]) 51 | @permission_classes([IsAuthenticated]) 52 | def user_logout(request): 53 | request.auth.delete() 54 | logout(request) 55 | 56 | return Response('User Logged out successfully') 57 | 58 | 59 | @api_view(["GET"]) 60 | @permission_classes([IsAuthenticated]) 61 | def current_team(request): 62 | serializers = TeamSerializers(request.auth.team) 63 | return Response(serializers.data) 64 | 65 | 66 | @api_view(["GET"]) 67 | @permission_classes([IsAuthenticated]) 68 | def available_team(request): 69 | team = Team.objects.filter(teammember__user=request.user, teammember__verified=True) 70 | serializer = TeamSerializers(team, many=True) 71 | return Response(serializer.data) 72 | 73 | 74 | @api_view(["POST"]) 75 | @permission_classes([IsAuthenticated]) 76 | def change_team(request): 77 | if 'id' not in request.data: 78 | return Response({'error': 'Team id required'}, status=status.HTTP_400_BAD_REQUEST) 79 | 80 | team_member = TeamMember.objects.filter(user=request.user, team__id=request.data['id'], verified=True) 81 | if len(team_member) < 1: 82 | return Response({'error': 'Invalid team id'}, status=status.HTTP_400_BAD_REQUEST) 83 | 84 | auth = request.auth 85 | auth.team_member = team_member[0] 86 | auth.save() 87 | 88 | return Response({'success': True}) 89 | 90 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'monapi.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /monapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/monapi/__init__.py -------------------------------------------------------------------------------- /monapi/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for monapi project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'monapi.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /monapi/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for monapi project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | from dotenv import load_dotenv 15 | 16 | from corsheaders.defaults import default_headers 17 | 18 | 19 | import os 20 | import sys 21 | 22 | 23 | # Testing flag 24 | TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' 25 | 26 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 27 | BASE_DIR = Path(__file__).resolve().parent.parent 28 | 29 | load_dotenv() # take environment variables from .env. 30 | 31 | # Quick-start development settings - unsuitable for production 32 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 33 | 34 | # SECURITY WARNING: keep the secret key used in production secret! 35 | SECRET_KEY = 'django-insecure-bj!6dkqu!+5iips82i=ty_wtax+5%1$f#54^q^=082c&^wnocq' 36 | if os.getenv('SECRET_KEY', '') != '': 37 | SECRET_KEY = os.getenv('SECRET_KEY') 38 | 39 | # SECURITY WARNING: don't run with debug turned on in production! 40 | DEBUG = False 41 | if os.getenv('PRODUCTION') == 'False': 42 | DEBUG = True 43 | 44 | ALLOWED_HOSTS = ['*'] 45 | if os.getenv('PRODUCTION') == 'True': 46 | ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '').split(',') 47 | 48 | CSRF_TRUSTED_ORIGINS = ["http://*", "https://*"] 49 | if os.getenv('PRODUCTION') == 'True': 50 | CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', '').split(',') 51 | 52 | # Application definition 53 | 54 | INSTALLED_APPS = [ 55 | # Django core 56 | 'django.contrib.admin', 57 | 'django.contrib.auth', 58 | 'django.contrib.contenttypes', 59 | 'django.contrib.sessions', 60 | 'django.contrib.messages', 61 | 'django.contrib.staticfiles', 62 | 63 | # Rest Framework 64 | 'corsheaders', 65 | 'rest_framework', 66 | 'rest_framework.authtoken', 67 | 68 | # App module 69 | 'alerts', 70 | 'apimonitor', 71 | 'cron', 72 | 'register', 73 | 'password_validators', 74 | 'login', 75 | 'error_logs', 76 | 'forget_password', 77 | 'apitest', 78 | 'team_management', 79 | 'invite_team_members', 80 | 'statuspage', 81 | ] 82 | 83 | MIDDLEWARE = [ 84 | 'corsheaders.middleware.CorsMiddleware', 85 | 'django.middleware.security.SecurityMiddleware', 86 | 'django.contrib.sessions.middleware.SessionMiddleware', 87 | 'django.middleware.common.CommonMiddleware', 88 | 'django.middleware.csrf.CsrfViewMiddleware', 89 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 90 | 'django.contrib.messages.middleware.MessageMiddleware', 91 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 92 | ] 93 | 94 | REST_FRAMEWORK = { 95 | 'DEFAULT_AUTHENTICATION_CLASSES': [ 96 | 'login.auth.MonAPITokenAuthentication', 97 | ], 98 | 'DEFAULT_PERMISSION_CLASSES': [ 99 | 'rest_framework.permissions.IsAuthenticated', 100 | ], 101 | 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 102 | 'PAGE_SIZE': 10, 103 | } 104 | 105 | if os.getenv('PRODUCTION', '') == 'True': 106 | REST_FRAMEWORK = { 107 | 'DEFAULT_AUTHENTICATION_CLASSES': [ 108 | 'login.auth.MonAPITokenAuthentication', 109 | ], 110 | 'DEFAULT_PERMISSION_CLASSES': [ 111 | 'rest_framework.permissions.IsAuthenticated', 112 | ], 113 | 'DEFAULT_RENDERER_CLASSES': ( 114 | 'rest_framework.renderers.JSONRenderer', 115 | ), 116 | 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 117 | 'PAGE_SIZE': 10, 118 | } 119 | 120 | ROOT_URLCONF = 'monapi.urls' 121 | 122 | TEMPLATES = [ 123 | { 124 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 125 | 'DIRS': [os.path.join(BASE_DIR, 'template')], 126 | 'APP_DIRS': True, 127 | 'OPTIONS': { 128 | 'context_processors': [ 129 | 'django.template.context_processors.debug', 130 | 'django.template.context_processors.request', 131 | 'django.contrib.auth.context_processors.auth', 132 | 'django.contrib.messages.context_processors.messages', 133 | ], 134 | }, 135 | }, 136 | ] 137 | 138 | WSGI_APPLICATION = 'monapi.wsgi.application' 139 | 140 | 141 | # Database 142 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 143 | 144 | DATABASES = { 145 | 'default': { 146 | 'ENGINE': 'django.db.backends.sqlite3', 147 | 'NAME': BASE_DIR / 'db.sqlite3', 148 | }, 149 | } 150 | 151 | # Database for testing 152 | # Use same db on main db and testing db to 153 | # support threading unit test 154 | if TESTING: 155 | DATABASES = { 156 | 'default': { 157 | 'ENGINE': 'django.db.backends.sqlite3', 158 | 'NAME': BASE_DIR / 'test.db.sqlite3', 159 | 'TEST': { 160 | 'NAME': BASE_DIR / 'test.db.sqlite3' 161 | } 162 | }, 163 | } 164 | 165 | # Database for production 166 | if os.getenv('PRODUCTION') == 'True': 167 | DATABASES = { 168 | 'default': { 169 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 170 | 'NAME': os.getenv('DB_NAME'), 171 | 'USER': os.getenv('DB_USER'), 172 | 'PASSWORD': os.getenv('DB_PASSWORD'), 173 | 'HOST': os.getenv('DB_HOST'), 174 | 'PORT': os.getenv('DB_PORT'), 175 | } 176 | } 177 | 178 | 179 | # Password validation 180 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 181 | 182 | AUTH_PASSWORD_VALIDATORS = [ 183 | { 184 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 185 | }, 186 | { 187 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 188 | }, 189 | { 190 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 191 | }, 192 | { 193 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 194 | }, 195 | { 196 | 'NAME': 'password_validators.validators.NumberValidator', 197 | }, 198 | { 199 | 'NAME': 'password_validators.validators.UppercaseValidator', 200 | }, 201 | ] 202 | 203 | 204 | # Internationalization 205 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 206 | 207 | LANGUAGE_CODE = 'en-us' 208 | 209 | TIME_ZONE = 'Asia/Jakarta' 210 | 211 | USE_I18N = True 212 | 213 | USE_TZ = True 214 | 215 | 216 | # Static files (CSS, JavaScript, Images) 217 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 218 | 219 | PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) 220 | 221 | STATIC_URL = '/static/' 222 | STATIC_ROOT = os.path.join(PROJECT_ROOT, 'static') 223 | if os.getenv('STATIC_ROOT', '') != '': 224 | STATIC_ROOT = os.getenv('STATIC_ROOT') 225 | 226 | # Extra places for collectstatic to find static files. 227 | STATICFILES_DIRS = [ 228 | os.path.join(BASE_DIR, 'static'), 229 | ] 230 | 231 | # Media configuration 232 | MEDIA_URL = '/uploads/' 233 | MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') 234 | if os.getenv('MEDIA_ROOT', '') != '': 235 | MEDIA_ROOT = os.getenv('MEDIA_ROOT') 236 | 237 | # DRF CORS configuration 238 | CORS_ALLOWED_ORIGINS = [ 239 | 'http://localhost:8080', 240 | ] 241 | 242 | CORS_ALLOW_HEADERS = list(default_headers) + [ 243 | 'baggage', 244 | 'sentry-trace', 245 | ] 246 | 247 | if os.getenv('PRODUCTION', '') == 'True': 248 | CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',') 249 | 250 | # Server mail configuration 251 | EMAIL_HOST = os.getenv('EMAIL_HOST', '') 252 | EMAIL_PORT = int(os.getenv('EMAIL_PORT', 25)) 253 | EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '') 254 | EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '') 255 | EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'False') == 'True' 256 | EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'False') == 'True' 257 | DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', '') 258 | 259 | # Default primary key field type 260 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 261 | 262 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -------------------------------------------------------------------------------- /monapi/urls.py: -------------------------------------------------------------------------------- 1 | """monapi URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls.static import static 18 | from django.contrib import admin 19 | from django.urls import path, include 20 | 21 | from rest_framework import routers 22 | 23 | 24 | router = routers.DefaultRouter() 25 | 26 | urlpatterns = [ 27 | path('', include(router.urls)), 28 | path('admin/', admin.site.urls), 29 | path('register/', include('register.urls')), 30 | path('forget-password/', include('forget_password.urls')), 31 | path('monitor/', include('apimonitor.urls')), 32 | path('auth/', include('login.urls')), 33 | path('error-logs/', include('error_logs.urls')), 34 | path('alerts/', include('alerts.urls')), 35 | path('api-test/', include('apitest.urls')), 36 | path('team-management/', include('team_management.urls')), 37 | path('invite-member/', include('invite_team_members.urls')), 38 | path('status-page/', include('statuspage.urls')), 39 | ] 40 | 41 | if settings.DEBUG: 42 | urlpatterns = urlpatterns + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 43 | -------------------------------------------------------------------------------- /monapi/utils.py: -------------------------------------------------------------------------------- 1 | def try_parse_int(string): 2 | try: 3 | return int(string) 4 | except ValueError: 5 | return False -------------------------------------------------------------------------------- /monapi/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for monapi project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'monapi.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /password_validators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/password_validators/__init__.py -------------------------------------------------------------------------------- /password_validators/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /password_validators/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PasswordValidatorsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'password_validators' 7 | -------------------------------------------------------------------------------- /password_validators/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/password_validators/migrations/__init__.py -------------------------------------------------------------------------------- /password_validators/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /password_validators/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.core.exceptions import ValidationError 3 | from password_validators.validators import NumberValidator, UppercaseValidator 4 | 5 | class NumberValidatorTestCase(TestCase): 6 | def test_number_validator_invalid_string(self): 7 | validator = NumberValidator() 8 | with self.assertRaises(ValidationError) as context: 9 | validator.validate('abcdefg') 10 | 11 | self.assertTrue('The password must contain at least 1 digit, 0-9.' in str(context.exception)) 12 | 13 | def test_number_validator_valid_string(self): 14 | validator = NumberValidator() 15 | valid = validator.validate('abcdefg123') 16 | self.assertEqual(valid, None) 17 | 18 | def test_number_validator_help_text(self): 19 | validator = NumberValidator() 20 | help_text = validator.get_help_text() 21 | self.assertEqual(help_text, "Your password must contain at least 1 digit.") 22 | 23 | 24 | class UppsercaseValidatorTestCase(TestCase): 25 | def test_upper_case_validator_invalid_string(self): 26 | validator = UppercaseValidator() 27 | with self.assertRaises(ValidationError) as context: 28 | validator.validate('abcdefg') 29 | 30 | self.assertTrue('The password must contain at least 1 uppercase letter, A-Z.' in str(context.exception)) 31 | 32 | def test_upper_case_validator_valid_string(self): 33 | validator = UppercaseValidator() 34 | valid = validator.validate('ABCDefg') 35 | self.assertEqual(valid, None) 36 | 37 | def test_upper_case_validator_help_text(self): 38 | validator = UppercaseValidator() 39 | help_text = validator.get_help_text() 40 | self.assertEqual(help_text, "Your password must contain at least 1 uppercase letter.") 41 | -------------------------------------------------------------------------------- /password_validators/validators.py: -------------------------------------------------------------------------------- 1 | import re 2 | from django.core.exceptions import ValidationError 3 | from django.utils.translation import gettext as _, ngettext 4 | 5 | 6 | class NumberValidator(object): 7 | def validate(self, password, user=None): 8 | if not re.findall('\d', password): 9 | raise ValidationError( 10 | _("The password must contain at least 1 digit, 0-9."), 11 | code='password_contains_no_number', 12 | ) 13 | 14 | def get_help_text(self): 15 | return ngettext( 16 | "Your password must contain at least 1 digit.", 17 | "Your password must contain at least 1 digits.", 18 | 1, 19 | ) % {'min_length': 1} 20 | 21 | class UppercaseValidator(object): 22 | def validate(self, password, user=None): 23 | if not re.findall('[A-Z]', password): 24 | raise ValidationError( 25 | _("The password must contain at least 1 uppercase letter, A-Z."), 26 | code='password_contains_no_capital', 27 | ) 28 | def get_help_text(self): 29 | return ngettext( 30 | "Your password must contain at least 1 uppercase letter.", 31 | "Your password must contain at least 1 uppercase letters.", 32 | 1, 33 | ) % {'min_length': 1} 34 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Describe your changes 2 | Describe what changed in this PR 3 | 4 | ### Backlog name 5 | Give name of backlog / task this PR relates to 6 | 7 | ### Acceptance criteria 8 | - Please copy acceptance criteria from backlog info 9 | 10 | ### Example Request 11 | ``` 12 | Put your API CURL and example response here 13 | ``` 14 | 15 | ### Checklist 16 | - [ ] I have performed a self-review of my code 17 | - [ ] Code successfully run on local 18 | - [ ] Feature / changes tested on local 19 | - [ ] No failing unit test 20 | - [ ] Passed UAT Test 21 | - [ ] Sonarqube tested 22 | - [ ] Required any changes to environment variable 23 | -------------------------------------------------------------------------------- /register/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/register/__init__.py -------------------------------------------------------------------------------- /register/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from register.models import VerifiedUser, VerifiedUserToken 3 | 4 | admin.site.register(VerifiedUser) 5 | admin.site.register(VerifiedUserToken) 6 | -------------------------------------------------------------------------------- /register/api/tests.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | from rest_framework import status 3 | 4 | 5 | class APIViewTestCase(APITestCase): 6 | def test_user_can_register(self): 7 | response = self.client.post( 8 | '/register/api', 9 | { 10 | 'email': 'user1@gmail.com', 11 | 'password': 'Abcd1234', 12 | 'password2': 'Abcd1234' 13 | } 14 | ) 15 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 16 | -------------------------------------------------------------------------------- /register/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-12-03 09:21 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='VerifiedUser', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('verified', models.BooleanField(default=False)), 23 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 24 | ], 25 | ), 26 | migrations.CreateModel( 27 | name='VerifiedUserToken', 28 | fields=[ 29 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ('key', models.CharField(default=uuid.uuid4, max_length=256)), 31 | ('verified_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='register.verifieduser')), 32 | ], 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /register/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/register/migrations/__init__.py -------------------------------------------------------------------------------- /register/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | import uuid 4 | 5 | 6 | class VerifiedUser(models.Model): 7 | user = models.ForeignKey(User, on_delete=models.CASCADE) 8 | verified = models.BooleanField(default=False) 9 | 10 | 11 | class VerifiedUserToken(models.Model): 12 | verified_user = models.ForeignKey(VerifiedUser, on_delete=models.CASCADE) 13 | key = models.CharField(max_length=256, default=uuid.uuid4) 14 | -------------------------------------------------------------------------------- /register/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.contrib.auth.password_validation import validate_password 3 | from django.core.exceptions import ValidationError 4 | from rest_framework import serializers 5 | 6 | 7 | class RegistrationSerializer(serializers.ModelSerializer): 8 | password2 = serializers.CharField(style={'input_type': 'password'}, write_only=True) 9 | 10 | class Meta: 11 | model = User 12 | fields = ['email', 'password', 'password2'] 13 | extra_kwargs = { 14 | 'email': {'required': True}, 15 | 'password': {'write_only': True} 16 | } 17 | 18 | def validate_password(self, password): 19 | try: 20 | validate_password(password) 21 | except ValidationError as exc: 22 | raise serializers.ValidationError(str(exc)) 23 | return password 24 | 25 | def save(self): 26 | user = User( 27 | email=self.validated_data['email'], 28 | username=self.validated_data['email'] 29 | ) 30 | 31 | password = self.validated_data['password'] 32 | password2 = self.validated_data['password2'] 33 | if password != password2: 34 | raise serializers.ValidationError({'password': 'Password must match.'}) 35 | user.set_password(password) 36 | user.save() 37 | return user 38 | 39 | 40 | class VerifiedUserTokenSerializer(serializers.Serializer): 41 | key = serializers.CharField(max_length=256) 42 | -------------------------------------------------------------------------------- /register/tests.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | from rest_framework import status 3 | from django.urls import reverse 4 | from django.contrib.auth.models import User 5 | 6 | from login.models import Team, TeamMember 7 | from register.models import VerifiedUser, VerifiedUserToken 8 | 9 | 10 | class APIViewTestCase(APITestCase): 11 | def test_user_can_register(self): 12 | response = self.client.post( 13 | reverse('register-api'), 14 | { 15 | 'email': 'user1@gmail.com', 16 | 'password': 'B0tch1ng', 17 | 'password2': 'B0tch1ng' 18 | }, 19 | ) 20 | # Check user is created 21 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 22 | self.assertEqual(response.data['response'], 'Successfully registered new user.') 23 | self.assertEqual(response.data['email'], 'user1@gmail.com') 24 | self.assertEqual(User.objects.all().count(), 1) 25 | 26 | # Check user is saved correctly 27 | new_user = User.objects.get_by_natural_key("user1@gmail.com") 28 | self.assertEqual(new_user.get_username(), "user1@gmail.com") 29 | self.assertEqual(new_user.check_password('B0tch1ng'), True) 30 | 31 | self.assertEqual(Team.objects.count(), 1) 32 | self.assertEqual(Team.objects.all()[0].name, "User1's Workspace") 33 | self.assertEqual(TeamMember.objects.count(), 1) 34 | self.assertEqual(TeamMember.objects.all()[0].user, new_user) 35 | 36 | def test_user_password_must_match(self): 37 | response = self.client.post( 38 | reverse('register-api'), 39 | { 40 | 'email': 'user2@gmail.com', 41 | 'password': 'Aaaa1234', 42 | 'password2': 'Bbbb1234' 43 | } 44 | ) 45 | 46 | self.assertEqual(response.data['password'], 'Password must match.') 47 | self.assertEqual(response.status_code, 400) 48 | 49 | def test_user_password_must_follow_policy(self): 50 | response = self.client.post( 51 | reverse('register-api'), 52 | { 53 | 'email': 'user3@gmail.com', 54 | 'password': 'a', 55 | 'password2': 'a' 56 | } 57 | ) 58 | 59 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 60 | self.assertEqual(response.data['password'][0].title(), 61 | str(['This Password Is Too Short. It Must Contain At Least 8 Characters.', 62 | 'This Password Is Too Common.', 63 | 'The Password Must Contain At Least 1 Digit, 0-9.', 64 | 'The Password Must Contain At Least 1 Uppercase Letter, A-Z.']) 65 | ) 66 | 67 | def test_only_one_user_per_email(self): 68 | response1 = self.client.post( 69 | reverse('register-api'), 70 | { 71 | 'email': 'user1@gmail.com', 72 | 'password': 'B0tch1ng', 73 | 'password2': 'B0tch1ng' 74 | }, 75 | ) 76 | 77 | response2 = self.client.post( 78 | reverse('register-api'), 79 | { 80 | 'email': 'user1@gmail.com', 81 | 'password': 'B0tch1ng', 82 | 'password2': 'B0tch1ng' 83 | }, 84 | ) 85 | self.assertEqual(response2.status_code, status.HTTP_400_BAD_REQUEST) 86 | self.assertEqual(response2.data['response'], 87 | 'User already registered! Please use a different email to register.') 88 | 89 | class TestUserVerification(APITestCase): 90 | 91 | def helper_register(self, email="user1@gmail.com", password="B0tch1ng"): 92 | response = self.client.post( 93 | reverse('register-api'), 94 | { 95 | 'email': email, 96 | 'password': password, 97 | 'password2': password, 98 | } 99 | ) 100 | return response 101 | 102 | def test_register_create_an_unverified_user(self): 103 | self.helper_register() 104 | user = User.objects.get(email='user1@gmail.com') 105 | verified_user = VerifiedUser.objects.get(user=user) 106 | self.assertEqual(verified_user.verified, False) 107 | 108 | def test_passing_valid_key_verify_an_unverified_user(self): 109 | self.helper_register() 110 | user = User.objects.get(email='user1@gmail.com') 111 | verified_user = VerifiedUser.objects.get(user=user) 112 | verified_user_token = VerifiedUserToken.objects.get(verified_user=verified_user) 113 | 114 | self.assertEqual(verified_user.verified, False) 115 | response = self.client.post( 116 | reverse('verify-user'), 117 | { 118 | 'key': verified_user_token.key 119 | } 120 | ) 121 | self.assertEqual(response.data['response'], 'Success') 122 | verified_user = VerifiedUser.objects.get(user=user) 123 | self.assertEqual(verified_user.verified, True) 124 | 125 | def test_passing_invalid_key_verify_an_unverified_user(self): 126 | self.helper_register() 127 | 128 | response = self.client.post( 129 | reverse('verify-user'), 130 | { 131 | 'key': 'false-key' 132 | } 133 | ) 134 | self.assertEqual(response.data['response'], 'Token is invalid.') 135 | 136 | def test_passing_non_key_verify_an_unverified_user(self): 137 | self.helper_register() 138 | 139 | response = self.client.post( 140 | reverse('verify-user'), 141 | { 142 | 'invalid-key': "None" 143 | } 144 | ) 145 | self.assertEqual(response.data['response'], 'Pass a valid token.') -------------------------------------------------------------------------------- /register/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from register.views import registration_view, verify_view 3 | 4 | urlpatterns = [ 5 | path('api', registration_view, name='register-api'), 6 | path('verify', verify_view, name='verify-user') 7 | ] -------------------------------------------------------------------------------- /register/views.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from rest_framework.response import Response 4 | from rest_framework.decorators import api_view, authentication_classes, permission_classes 5 | from rest_framework import status 6 | 7 | from django.db.utils import IntegrityError 8 | from register.serializers import RegistrationSerializer, VerifiedUserTokenSerializer 9 | from login.models import Team, TeamMember 10 | from register.models import VerifiedUser, VerifiedUserToken 11 | 12 | from django.core.mail import send_mail 13 | from django.template.loader import render_to_string 14 | from django.core.exceptions import ObjectDoesNotExist 15 | 16 | @api_view(['POST', ]) 17 | @authentication_classes([]) 18 | @permission_classes([]) 19 | def registration_view(request): 20 | serializer = RegistrationSerializer(data=request.data) 21 | data = {} 22 | if serializer.is_valid(): 23 | try: 24 | user = serializer.save() 25 | username = user.username.split('@')[0] 26 | team = Team.objects.create(name=f"{username.title()}'s Workspace") 27 | TeamMember.objects.create(user=user, team=team, verified=True) 28 | verified_user = VerifiedUser.objects.create(user=user) 29 | token = VerifiedUserToken.objects.create(verified_user=verified_user) 30 | verify_url = f"{os.environ.get('FRONTEND_URL', '')}/verify?key={token.key}" 31 | 32 | email_content = f''' 33 | To verify your registration on MonAPI, please follow the link below.\n 34 | If you did not register on MonAPI, you can ignore this email. \n\n 35 | {verify_url} 36 | ''' 37 | 38 | email_content_html = render_to_string('email/verify_email.html', { 39 | 'verify_url': verify_url, 40 | }) 41 | 42 | send_mail( 43 | f'MonAPI User Verification', 44 | email_content, 45 | None, 46 | [user.email], 47 | html_message=email_content_html, 48 | fail_silently=False, 49 | ) 50 | 51 | except IntegrityError: 52 | data['response'] = 'User already registered! Please use a different email to register.' 53 | return Response(data, status=status.HTTP_400_BAD_REQUEST) 54 | data['response'] = 'Successfully registered new user.' 55 | data['email'] = user.email 56 | else: 57 | data = serializer.errors 58 | return Response(data, status=status.HTTP_400_BAD_REQUEST) 59 | return Response(data, status=status.HTTP_201_CREATED) 60 | 61 | @api_view(['POST', ]) 62 | @authentication_classes([]) 63 | @permission_classes([]) 64 | def verify_view(request): 65 | serializer = VerifiedUserTokenSerializer(data=request.data) 66 | if serializer.is_valid(): 67 | try: 68 | verify_token = VerifiedUserToken.objects.get(key=serializer.validated_data['key']) 69 | verified_user = verify_token.verified_user 70 | verified_user.verified = True 71 | verified_user.save() 72 | data = {'response': 'Success'} 73 | verify_token.delete() 74 | return Response(data=data, status=status.HTTP_200_OK) 75 | except ObjectDoesNotExist: 76 | data = {'response': 'Token is invalid.'} 77 | return Response(data=data, status=status.HTTP_400_BAD_REQUEST) 78 | else: 79 | data = {'response': 'Pass a valid token.'} 80 | return Response(data=data, status=status.HTTP_400_BAD_REQUEST) 81 | 82 | 83 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.3 2 | aiosignal==1.3.1 3 | asgiref==3.5.2 4 | async-timeout==4.0.2 5 | attrs==22.1.0 6 | backports.zoneinfo==0.2.1 7 | certifi==2022.9.24 8 | charset-normalizer==2.1.1 9 | coverage==6.4.4 10 | deepdiff==6.2.1 11 | Deprecated==1.2.13 12 | discord-webhook==0.17.0 13 | discord.py==2.0.1 14 | Django==4.1.2 15 | django-cors-headers==3.13.0 16 | django-debug-toolbar==3.7.0 17 | django-query-profiler==0.9 18 | django-redis==5.2.0 19 | django-test-migrations==1.2.0 20 | djangorestframework==3.13.1 21 | frozenlist==1.3.3 22 | idna==3.4 23 | isort==5.10.1 24 | mmh3==3.0.0 25 | mo-dots==9.230.22310 26 | mo-future==6.230.22310 27 | mo-imports==7.230.22310 28 | mo-parsing==8.233.22310 29 | mo-sql-parsing==8.235.22313 30 | multidict==6.0.2 31 | ordered-set==4.1.0 32 | packaging==21.3 33 | pyparsing==3.0.9 34 | python-dotenv==0.21.0 35 | pytz==2022.2.1 36 | redis==4.3.4 37 | requests==2.28.1 38 | sqlparse==0.4.2 39 | typing_extensions==4.4.0 40 | tzdata==2022.2 41 | urllib3==1.26.12 42 | wrapt==1.14.1 43 | yarl==1.8.1 44 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=MonAPI-xyz_MonAPI 2 | sonar.organization=monapi-xyz 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=MonAPI 6 | #sonar.projectVersion=1.0 7 | 8 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 9 | #sonar.sources=. 10 | 11 | # Encoding of the source code. Default is default system encoding 12 | #sonar.sourceEncoding=UTF-8 13 | 14 | # Set sonar python version 15 | sonar.python.version=3.8 16 | 17 | # Coverage report path 18 | sonar.python.coverage.reportPaths=coverage.xml 19 | 20 | # Coverage exclusions for management and tests file 21 | sonar.coverage.exclusions=monapi/settings.py, monapi/wsgi.py, monapi/asgi.py, manage.py, **/tests.py 22 | 23 | # Exclude test.py from any scanning is expected 24 | # Exclude migrations file expected due to django auto-generated file 25 | sonar.exclusions=**/tests.py, **/migrations/** 26 | -------------------------------------------------------------------------------- /statuspage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/statuspage/__init__.py -------------------------------------------------------------------------------- /statuspage/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from statuspage.models import StatusPageCategory, StatusPageConfiguration 4 | 5 | admin.site.register(StatusPageConfiguration) 6 | admin.site.register(StatusPageCategory) 7 | -------------------------------------------------------------------------------- /statuspage/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class StatuspageConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'statuspage' 7 | -------------------------------------------------------------------------------- /statuspage/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-29 02:00 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ('login', '0003_teammember_verified'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='StatusPageConfiguration', 18 | fields=[ 19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('path', models.CharField(blank=True, max_length=64)), 21 | ('team', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='login.team')), 22 | ], 23 | ), 24 | migrations.CreateModel( 25 | name='StatusPageCategory', 26 | fields=[ 27 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('name', models.CharField(max_length=256)), 29 | ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='login.team')), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /statuspage/migrations/0002_alter_statuspageconfiguration_path.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-29 14:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('statuspage', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='statuspageconfiguration', 15 | name='path', 16 | field=models.CharField(blank=True, max_length=64, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /statuspage/migrations/0003_alter_statuspageconfiguration_path.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-12-09 04:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('statuspage', '0002_alter_statuspageconfiguration_path'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='statuspageconfiguration', 15 | name='path', 16 | field=models.CharField(default=None, max_length=64, null=True, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /statuspage/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/statuspage/migrations/__init__.py -------------------------------------------------------------------------------- /statuspage/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from login.models import Team 3 | 4 | class StatusPageConfiguration(models.Model): 5 | team = models.OneToOneField(Team, on_delete=models.CASCADE) 6 | path = models.CharField(max_length=64, null=True, blank=False, unique=True, default=None) 7 | 8 | class StatusPageCategory(models.Model): 9 | team = models.ForeignKey(Team, on_delete=models.CASCADE) 10 | name = models.CharField(max_length=256) 11 | -------------------------------------------------------------------------------- /statuspage/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from statuspage.models import StatusPageConfiguration, StatusPageCategory 4 | 5 | class StatusPageConfgurationSerializers(serializers.ModelSerializer): 6 | class Meta: 7 | model = StatusPageConfiguration 8 | fields = [ 9 | 'path', 10 | ] 11 | 12 | 13 | class StatusPageCategorySerializers(serializers.ModelSerializer): 14 | class Meta: 15 | model = StatusPageCategory 16 | fields = [ 17 | 'id', 18 | 'team', 19 | 'name', 20 | ] 21 | 22 | read_only_fields = ['team'] 23 | 24 | class APIMonitorSuccessRateSerializer(serializers.Serializer): 25 | start_time = serializers.DateTimeField() 26 | end_time = serializers.DateTimeField() 27 | success = serializers.IntegerField() 28 | failed = serializers.IntegerField() 29 | 30 | class StatusPageDashboardSerializers(serializers.ModelSerializer): 31 | success_rate_category = APIMonitorSuccessRateSerializer(many=True) 32 | class Meta: 33 | model = StatusPageCategory 34 | fields = [ 35 | 'id', 36 | 'name', 37 | 'success_rate_category', 38 | ] -------------------------------------------------------------------------------- /statuspage/tests.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | from django.utils import timezone 3 | from django.urls import reverse 4 | from django.contrib.auth.models import User 5 | from django.conf import settings 6 | from datetime import datetime 7 | 8 | from rest_framework.test import APITestCase 9 | from login.models import Team, TeamMember, MonAPIToken 10 | from apimonitor.models import APIMonitor, APIMonitorResult 11 | from statuspage.models import StatusPageConfiguration, StatusPageCategory 12 | 13 | class StatusPageConfigTest(APITestCase): 14 | test_url = reverse('statuspage-config') 15 | 16 | def test_status_page_config_get_first_time_then_return_success(self): 17 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 18 | team = Team.objects.create(name='test team') 19 | team_member = TeamMember.objects.create(team=team, user=user) 20 | 21 | token = MonAPIToken.objects.create(team_member=team_member) 22 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 23 | 24 | response = self.client.get(self.test_url, format='json', **header) 25 | self.assertEqual(response.status_code, 200) 26 | self.assertEqual(response.data, { 27 | "path": None 28 | }) 29 | 30 | def test_status_page_config_get_when_exists_then_return_success(self): 31 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 32 | team = Team.objects.create(name='test team') 33 | team_member = TeamMember.objects.create(team=team, user=user) 34 | 35 | token = MonAPIToken.objects.create(team_member=team_member) 36 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 37 | 38 | StatusPageConfiguration.objects.create(team=team, path="testpath") 39 | 40 | response = self.client.get(self.test_url, format='json', **header) 41 | self.assertEqual(response.status_code, 200) 42 | self.assertEqual(response.data, { 43 | "path": "testpath" 44 | }) 45 | 46 | def test_status_page_config_update_then_return_success(self): 47 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 48 | team = Team.objects.create(name='test team') 49 | team_member = TeamMember.objects.create(team=team, user=user) 50 | 51 | token = MonAPIToken.objects.create(team_member=team_member) 52 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 53 | 54 | req_body = { 55 | "path": "updatepath" 56 | } 57 | 58 | response = self.client.post(self.test_url, data=req_body, format='json', **header) 59 | self.assertEqual(response.status_code, 200) 60 | self.assertEqual(response.data, { 61 | "path": "updatepath" 62 | }) 63 | 64 | status_config = StatusPageConfiguration.objects.get(team=team) 65 | self.assertEqual(status_config.path, "updatepath") 66 | 67 | def test_status_page_config_update_when_empty_param_then_return_error(self): 68 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 69 | team = Team.objects.create(name='test team') 70 | team_member = TeamMember.objects.create(team=team, user=user) 71 | 72 | token = MonAPIToken.objects.create(team_member=team_member) 73 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 74 | 75 | req_body = { 76 | "path": "", 77 | } 78 | 79 | response = self.client.post(self.test_url, data=req_body, format='json', **header) 80 | self.assertEqual(response.status_code, 400) 81 | self.assertEqual(response.data, { 82 | "path": [ 83 | "This field may not be blank." 84 | ] 85 | }) 86 | 87 | def test_status_page_config_update_when_none_then_return_success(self): 88 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 89 | team = Team.objects.create(name='test team') 90 | team_member = TeamMember.objects.create(team=team, user=user) 91 | 92 | token = MonAPIToken.objects.create(team_member=team_member) 93 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 94 | 95 | req_body = { 96 | "path": None, 97 | } 98 | 99 | response = self.client.post(self.test_url, data=req_body, format='json', **header) 100 | self.assertEqual(response.status_code, 200) 101 | self.assertEqual(response.data, { 102 | "path": None 103 | }) 104 | 105 | class StatusPageCategoryTest(APITestCase): 106 | test_url = reverse('statuspage-category-list') 107 | 108 | def test_status_page_category_get_then_return_list_category(self): 109 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 110 | team = Team.objects.create(name='test team') 111 | team_member = TeamMember.objects.create(team=team, user=user) 112 | 113 | token = MonAPIToken.objects.create(team_member=team_member) 114 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 115 | 116 | StatusPageCategory.objects.create(team=team, name='category1') 117 | 118 | response = self.client.get(self.test_url, format='json', **header) 119 | self.assertEqual(response.status_code, 200) 120 | self.assertEqual(response.data[0]['name'], 'category1') 121 | 122 | def test_status_page_category_post_then_create_new_category(self): 123 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 124 | team = Team.objects.create(name='test team') 125 | team_member = TeamMember.objects.create(team=team, user=user) 126 | 127 | token = MonAPIToken.objects.create(team_member=team_member) 128 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 129 | 130 | req_body = { 131 | "name": "abc" 132 | } 133 | 134 | response = self.client.post(self.test_url, data=req_body, format='json', **header) 135 | self.assertEqual(response.status_code, 201) 136 | self.assertEqual(response.data['name'], "abc") 137 | 138 | category = StatusPageCategory.objects.get(team=team) 139 | self.assertEqual(category.name, "abc") 140 | 141 | def test_status_page_category_post_invalid_param_then_return_error(self): 142 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 143 | team = Team.objects.create(name='test team') 144 | team_member = TeamMember.objects.create(team=team, user=user) 145 | 146 | token = MonAPIToken.objects.create(team_member=team_member) 147 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 148 | 149 | req_body = {} 150 | 151 | response = self.client.post(self.test_url, data=req_body, format='json', **header) 152 | self.assertEqual(response.status_code, 400) 153 | self.assertEqual(response.data, { 154 | "name": ["This field is required."] 155 | }) 156 | 157 | def test_status_page_category_delete_then_return_success(self): 158 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 159 | team = Team.objects.create(name='test team') 160 | team_member = TeamMember.objects.create(team=team, user=user) 161 | 162 | token = MonAPIToken.objects.create(team_member=team_member) 163 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 164 | 165 | status_page = StatusPageCategory.objects.create(team=team, name='categorydel') 166 | 167 | response = self.client.delete(reverse('statuspage-category-detail', kwargs={'pk': status_page.id}), format='json', **header) 168 | self.assertEqual(response.status_code, 204) 169 | 170 | count_status = StatusPageCategory.objects.filter(team=team).count() 171 | self.assertEqual(count_status, 0) 172 | 173 | def test_status_page_category_delete_not_exists_then_return_error(self): 174 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 175 | team = Team.objects.create(name='test team') 176 | team_member = TeamMember.objects.create(team=team, user=user) 177 | 178 | token = MonAPIToken.objects.create(team_member=team_member) 179 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 180 | 181 | response = self.client.delete(reverse('statuspage-category-detail', kwargs={'pk': 999}), format='json', **header) 182 | self.assertEqual(response.status_code, 404) 183 | 184 | 185 | class StatusPageDashboardTest(APITestCase): 186 | test_url = reverse('statuspage-dashboard') 187 | local_timezone = pytz.timezone(settings.TIME_ZONE) 188 | mock_current_time = local_timezone.localize(datetime(2022, 9, 20, 10)) 189 | 190 | def setUp(self): 191 | # Mock time function 192 | timezone.now = lambda: self.mock_current_time 193 | 194 | # status page dashboard deservedly access without authorization because for public 195 | def test_unauthorized_and_not_empty_status_page_category_in_api_monitor_then_return_success(self): 196 | # create dummy api monitor and the result 197 | team = Team.objects.create(name='test team') 198 | statusPageCategory1 = StatusPageCategory.objects.create(team=team, name='test-category1') 199 | statusPageCategory2 = StatusPageCategory.objects.create(team=team, name='test-category2') 200 | StatusPageConfiguration.objects.create(team=team, path='test-path') 201 | 202 | monitor1 = APIMonitor.objects.create( 203 | team=team, 204 | name='Test Name1', 205 | method='GET', 206 | url='Test Path', 207 | schedule='10MIN', 208 | previous_step=None, 209 | body_type='EMPTY', 210 | status_page_category=statusPageCategory1, 211 | ) 212 | 213 | monitor2 = APIMonitor.objects.create( 214 | team=team, 215 | name='Test Name2', 216 | method='GET', 217 | url='Test Path', 218 | schedule='10MIN', 219 | previous_step=None, 220 | body_type='EMPTY', 221 | status_page_category=statusPageCategory2, 222 | ) 223 | 224 | APIMonitorResult.objects.create( 225 | monitor=monitor1, 226 | execution_time=self.mock_current_time, 227 | response_time=100, 228 | success=True, 229 | status_code=500, 230 | log_response='Log Response', 231 | log_error='', 232 | ) 233 | 234 | APIMonitorResult.objects.create( 235 | monitor=monitor2, 236 | execution_time=self.mock_current_time, 237 | response_time=75, 238 | success=False, 239 | status_code=500, 240 | log_response='', 241 | log_error='Log Error' 242 | ) 243 | 244 | response = self.client.get(self.test_url, data={"path": "test-path"}, format='json') 245 | self.assertEqual(response.status_code, 200) 246 | self.assertEqual(len(response.data), 2) 247 | self.assertEqual(response.data[0]['name'], 'test-category1') 248 | self.assertEqual(response.data[0]['success_rate_category'][23]['success'], 1) 249 | self.assertEqual(response.data[1]['success_rate_category'][23]['failed'], 1) 250 | 251 | def test_empty_status_page_category_then_return_empty_status_page_dashboard(self): 252 | # create path 253 | team = Team.objects.create(name='test team') 254 | StatusPageConfiguration.objects.create(team=team, path='test-path') 255 | 256 | response = self.client.get(self.test_url, data={"path": "test-path"}, format='json') 257 | self.assertEqual(response.status_code, 200) 258 | self.assertEqual(len(response.data), 0) 259 | 260 | def test_path_doesnt_exist_then_return_404_not_found(self): 261 | response = self.client.get(self.test_url, data={"path": "no-path"}, format='json') 262 | self.assertEqual(response.status_code, 404) 263 | self.assertEqual(response.data['error'], "Please make sure your URL path is exist!") 264 | 265 | def test_category_are_exist_but_have_not_assigned_then_return_empty_list_in_success_rate_category(self): 266 | # create dummy data 267 | team = Team.objects.create(name='test team') 268 | StatusPageCategory.objects.create(team=team, name='test-category') 269 | StatusPageConfiguration.objects.create(team=team, path='test-path') 270 | 271 | monitor = APIMonitor.objects.create( 272 | team=team, 273 | name='Test Name1', 274 | method='GET', 275 | url='Test Path', 276 | schedule='10MIN', 277 | previous_step=None, 278 | body_type='EMPTY', 279 | ) 280 | 281 | APIMonitorResult.objects.create( 282 | monitor=monitor, 283 | execution_time=self.mock_current_time, 284 | response_time=100, 285 | success=True, 286 | status_code=500, 287 | log_response='Log Response', 288 | log_error='', 289 | ) 290 | 291 | response = self.client.get(self.test_url, data={"path": "test-path"}, format='json') 292 | self.assertEqual(response.status_code, 200) 293 | self.assertEqual(response.data[0]['name'], 'test-category') 294 | self.assertEqual(response.data[0]['success_rate_category'], []) -------------------------------------------------------------------------------- /statuspage/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from rest_framework import routers 3 | from statuspage.views import StatusPageConfigurationView, StatusPageCategoryViewSet, StatusPageDashboardViewSet 4 | 5 | router = routers.DefaultRouter() 6 | router.register(r'', StatusPageCategoryViewSet, basename='statuspage-category') 7 | 8 | urlpatterns = [ 9 | path('category/', include(router.urls)), 10 | path('config/', StatusPageConfigurationView.as_view(), name='statuspage-config'), 11 | path('dashboard/', StatusPageDashboardViewSet.as_view({'get': 'list'}), name='statuspage-dashboard'), 12 | ] -------------------------------------------------------------------------------- /statuspage/views.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from django.db.models import Count, Q 3 | from django.utils import timezone 4 | from rest_framework import views, status, viewsets, mixins 5 | from rest_framework.permissions import IsAuthenticated 6 | from rest_framework.response import Response 7 | 8 | from apimonitor.models import APIMonitorResult 9 | from statuspage.models import StatusPageConfiguration, StatusPageCategory 10 | from statuspage.serializers import StatusPageConfgurationSerializers, StatusPageCategorySerializers, StatusPageDashboardSerializers 11 | 12 | class StatusPageConfigurationView(views.APIView): 13 | permission_classes = [IsAuthenticated] 14 | 15 | def get(self, request, format=None): 16 | config, _ = StatusPageConfiguration.objects.get_or_create(team=request.auth.team) 17 | serializer = StatusPageConfgurationSerializers(config) 18 | return Response(serializer.data) 19 | 20 | def post(self, request, format=None): 21 | config, _ = StatusPageConfiguration.objects.get_or_create(team=request.auth.team) 22 | serializer = StatusPageConfgurationSerializers(config, data=request.data, partial=True) 23 | if serializer.is_valid(): 24 | serializer.save() 25 | return Response(serializer.data) 26 | 27 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 28 | 29 | 30 | class StatusPageCategoryViewSet(mixins.ListModelMixin, 31 | mixins.CreateModelMixin, 32 | mixins.DestroyModelMixin, 33 | viewsets.GenericViewSet): 34 | 35 | queryset = StatusPageCategory.objects.all() 36 | serializer_class = StatusPageCategorySerializers 37 | permission_classes = [IsAuthenticated] 38 | pagination_class = None 39 | 40 | def get_queryset(self): 41 | queryset = StatusPageCategory.objects.filter(team=self.request.auth.team) 42 | return queryset 43 | 44 | def create(self, request, *args, **kwargs): 45 | serializer = StatusPageCategorySerializers(data=request.data) 46 | if serializer.is_valid(): 47 | status_page = StatusPageCategory.objects.create( 48 | team = request.auth.team, 49 | name = serializer.data['name'], 50 | ) 51 | serializer = StatusPageCategorySerializers(status_page) 52 | return Response(serializer.data, status=status.HTTP_201_CREATED) 53 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 54 | 55 | 56 | class StatusPageDashboardViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): 57 | 58 | queryset = StatusPageCategory.objects.all() 59 | serializer_class = StatusPageDashboardSerializers 60 | permission_classes = [] 61 | pagination_class = None 62 | 63 | def list(self, request): 64 | status_page_config = StatusPageConfiguration.objects.filter(path=self.request.GET.get('path')) 65 | if len(status_page_config) == 0: 66 | return Response(data={"error": "Please make sure your URL path is exist!"}, status=status.HTTP_404_NOT_FOUND) 67 | 68 | queryset = StatusPageCategory.objects.filter(team=status_page_config[0].team) 69 | 70 | for category in queryset: 71 | success_rate_category = [] 72 | api_monitor_result_count = APIMonitorResult.objects \ 73 | .filter(monitor__status_page_category=category).count() 74 | 75 | if api_monitor_result_count == 0: 76 | category.success_rate_category = success_rate_category 77 | continue 78 | 79 | #success_rate per category 80 | last_24_hour = timezone.now() - timedelta(hours=24) 81 | for _ in range(24): 82 | start_time = last_24_hour 83 | end_time = last_24_hour + timedelta(hours=1) 84 | 85 | # Average success rate 86 | success_count = APIMonitorResult.objects.filter( 87 | monitor__status_page_category=category, 88 | execution_time__gte=start_time, 89 | execution_time__lte=end_time 90 | ).aggregate( 91 | s=Count('success', filter=Q(success=True)), 92 | f=Count('success', filter=Q(success=False)), 93 | total=Count('pk'), 94 | ) 95 | 96 | success_rate_category.append({ 97 | "start_time": start_time, 98 | "end_time" : end_time, 99 | "success": success_count['s'], 100 | "failed" : success_count['f'] 101 | }) 102 | 103 | last_24_hour += timedelta(hours=1) 104 | 105 | category.success_rate_category = success_rate_category 106 | 107 | serializer = StatusPageDashboardSerializers(queryset, many=True) 108 | return Response(serializer.data) -------------------------------------------------------------------------------- /team_management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/team_management/__init__.py -------------------------------------------------------------------------------- /team_management/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /team_management/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TeamManagementConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'team_management' 7 | -------------------------------------------------------------------------------- /team_management/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MonAPI-xyz/MonAPI/efeefe7989322ded686bf4a1c1a609ffee6e7a16/team_management/migrations/__init__.py -------------------------------------------------------------------------------- /team_management/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /team_management/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from login.models import Team, TeamMember 3 | from django.contrib.auth.models import User 4 | 5 | 6 | class TeamUserSerializers(serializers.ModelSerializer): 7 | class Meta: 8 | model = User 9 | fields = [ 10 | 'id', 11 | 'username', 12 | 'email', 13 | 'first_name', 14 | 'last_name', 15 | ] 16 | 17 | class TeamMemberSerializers(serializers.ModelSerializer): 18 | user = TeamUserSerializers() 19 | class Meta: 20 | model = TeamMember 21 | fields = [ 22 | 'team', 23 | 'user', 24 | 'verified', 25 | ] 26 | 27 | class TeamManagementSerializers(serializers.ModelSerializer): 28 | teammember = TeamMemberSerializers(many=True) 29 | class Meta: 30 | model = Team 31 | fields = [ 32 | 'id', 33 | 'name', 34 | 'logo', 35 | 'description', 36 | 'teammember', 37 | ] 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /team_management/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.contrib.auth.models import User 3 | from django.urls import reverse 4 | from rest_framework import status 5 | from rest_framework.test import APITestCase 6 | from login.models import Team, TeamMember, MonAPIToken 7 | from django.core.files.uploadedfile import SimpleUploadedFile 8 | 9 | class TeamManagementTests(APITestCase): 10 | list_url = reverse('team-management-list') 11 | current_url = reverse('team-management-current') 12 | 13 | def test_when_non_authenticated_then_return_unauthorized(self): 14 | new_team_request = { 15 | "name": "test team", 16 | "description": "test description", 17 | "logo": "testLogo.png", 18 | } 19 | 20 | response = self.client.post(self.list_url, data=new_team_request, format='json') 21 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 22 | self.assertEqual(response.data, { 23 | "detail": "Authentication credentials were not provided." 24 | }) 25 | 26 | def test_when_authenticated_and_empty_team_name_then_return_bad_request(self): 27 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 28 | team = Team.objects.create(name='myteam') 29 | team_member = TeamMember.objects.create(team=team, user=user) 30 | 31 | token = MonAPIToken.objects.create(team_member=team_member) 32 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 33 | 34 | new_team_request = { 35 | "name": "", 36 | } 37 | 38 | response = self.client.post(self.list_url, data=new_team_request, format='json', **header) 39 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 40 | self.assertEqual(response.data, { 41 | "error": "Please make sure your [team name] is exist!" 42 | }) 43 | 44 | def test_when_authenticated_and_not_empty_team_name_then_return_success(self): 45 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 46 | team = Team.objects.create(name='myteam') 47 | team_member = TeamMember.objects.create(team=team, user=user) 48 | 49 | token = MonAPIToken.objects.create(team_member=team_member) 50 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 51 | 52 | new_team_request = { 53 | "name": "test team", 54 | } 55 | 56 | response = self.client.post(self.list_url, data=new_team_request, format='json', **header) 57 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 58 | self.assertEqual(response.data['name'], "test team") 59 | 60 | def test_when_authenticated_and_complete_team_data_then_return_success(self): 61 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 62 | team = Team.objects.create(name='myteam') 63 | team_member = TeamMember.objects.create(team=team, user=user) 64 | 65 | token = MonAPIToken.objects.create(team_member=team_member) 66 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 67 | 68 | new_team_request = { 69 | "name": "test team", 70 | "description": "test description", 71 | "logo": "testLogo.png", 72 | } 73 | 74 | response = self.client.post(self.list_url, data=new_team_request, format='json', **header) 75 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 76 | self.assertEqual(response.data['name'], "test team") 77 | 78 | def test_when_authenticated_and_update_with_not_empty_team_name_then_return_bad_request(self): 79 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 80 | team = Team.objects.create(name='myteam') 81 | team_member = TeamMember.objects.create(team=team, user=user) 82 | 83 | token = MonAPIToken.objects.create(team_member=team_member) 84 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 85 | 86 | new_team_request = { 87 | "name": "test team", 88 | "description": "test description", 89 | "logo": "testLogo.png", 90 | } 91 | target_team_id = TeamMember.objects.filter(team=team)[:1].get().id 92 | edit_team_url = reverse('team-management-detail', kwargs={'pk': target_team_id}) 93 | response = self.client.put(edit_team_url, data=new_team_request, format='json', **header) 94 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 95 | self.assertEqual(response.data, { 96 | "error": "Team name cannot be changed!" 97 | }) 98 | 99 | def test_when_authenticated_and_update_with_required_team_data_then_return_success(self): 100 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 101 | team = Team.objects.create(name='myteam') 102 | team_member = TeamMember.objects.create(team=team, user=user) 103 | 104 | token = MonAPIToken.objects.create(team_member=team_member) 105 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 106 | 107 | new_team_request = { 108 | "description": "test description", 109 | "logo": "testLogo.png", 110 | } 111 | 112 | target_team_id = TeamMember.objects.filter(team=team)[:1].get().id 113 | edit_team_url = reverse('team-management-detail', kwargs={'pk': target_team_id}) 114 | response = self.client.put(edit_team_url, data=new_team_request, format='json', **header) 115 | self.assertEqual(response.status_code, status.HTTP_200_OK) 116 | self.assertEqual(response.data['name'], "myteam") 117 | 118 | 119 | def test_when_authenticated_and_update_with_new_image_then_return_success(self): 120 | 121 | image = SimpleUploadedFile(name='test_image.jpg', content=b'', content_type='image/jpeg') 122 | new_image = SimpleUploadedFile(name='test_new_image.jpg', content=b'', content_type='image/jpeg') 123 | 124 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 125 | team = Team.objects.create(name='myteam', description='123', logo=image) 126 | team_logo_path = team.logo.path 127 | 128 | team_member = TeamMember.objects.create(team=team, user=user) 129 | 130 | token = MonAPIToken.objects.create(team_member=team_member) 131 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 132 | 133 | new_team_request = { 134 | "description": "test description", 135 | "logo": new_image, 136 | } 137 | 138 | target_team_id = TeamMember.objects.filter(team=team)[:1].get().id 139 | edit_team_url = reverse('team-management-detail', kwargs={'pk': target_team_id}) 140 | response = self.client.put(edit_team_url, data=new_team_request, format='json', **header) 141 | self.assertEqual(response.status_code, status.HTTP_200_OK) 142 | self.assertEqual(response.data['name'], "myteam") 143 | self.assertEqual(os.path.exists(team_logo_path), False ) 144 | 145 | def test_when_authenticated_and_update_only_description_then_return_success(self): 146 | 147 | image = SimpleUploadedFile(name='test_image.jpg', content=b'', content_type='image/jpeg') 148 | 149 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 150 | team = Team.objects.create(name='myteam', description='123', logo=image) 151 | team_logo_path = team.logo.path 152 | 153 | team_member = TeamMember.objects.create(team=team, user=user) 154 | 155 | token = MonAPIToken.objects.create(team_member=team_member) 156 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 157 | 158 | new_team_request = { 159 | "description": "test description", 160 | } 161 | 162 | target_team_id = TeamMember.objects.filter(team=team)[:1].get().id 163 | edit_team_url = reverse('team-management-detail', kwargs={'pk': target_team_id}) 164 | response = self.client.put(edit_team_url, data=new_team_request, format='json', **header) 165 | self.assertEqual(response.status_code, status.HTTP_200_OK) 166 | self.assertEqual(response.data['name'], "myteam") 167 | self.assertEqual(os.path.exists(team_logo_path), True ) 168 | 169 | def test_when_authenticated_and_not_in_current_edit_team_then_return_bad_request(self): 170 | 171 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 172 | team = Team.objects.create(name='myteam') 173 | team_two = Team.objects.create(name='myteam2') 174 | team_member = TeamMember.objects.create(team=team, user=user) 175 | TeamMember.objects.create(team=team_two, user=user) 176 | 177 | token = MonAPIToken.objects.create(team_member=team_member) 178 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 179 | 180 | new_team_request = { 181 | "description": "test description", 182 | "logo": "testLogo.png", 183 | } 184 | 185 | target_team_id = TeamMember.objects.filter(team=team_two)[:1].get().id 186 | edit_team_url = reverse('team-management-detail', kwargs={'pk': target_team_id}) 187 | response = self.client.put(edit_team_url, data=new_team_request, format='json', **header) 188 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 189 | self.assertEqual(response.data['error'], 'Team not found') 190 | 191 | def test_retrieve(self): 192 | user = User.objects.create_user(username="test@test.com", email="test@test.com", password="Test1234") 193 | team = Team.objects.create(name='myteam') 194 | team_member = TeamMember.objects.create(team=team, user=user) 195 | 196 | token = MonAPIToken.objects.create(team_member=team_member) 197 | header = {'HTTP_AUTHORIZATION': f"Token {token.key}"} 198 | 199 | response = self.client.get(self.current_url, format='json', **header) 200 | self.assertEqual(response.data,{"id": 1, "name": "myteam", "logo": None, "description": "", "teammember": [{"team": 1, "user": {"id": 1, "username": "test@test.com", "email": "test@test.com", "first_name": "", "last_name": ""}, "verified": False}]}) 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | -------------------------------------------------------------------------------- /team_management/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from team_management import views 3 | from rest_framework import routers 4 | 5 | router = routers.DefaultRouter() 6 | router.register(r'', views.TeamManagementViewSet, basename='team-management') 7 | urlpatterns = [ 8 | path('', include(router.urls)), 9 | ] 10 | -------------------------------------------------------------------------------- /team_management/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets, mixins, status 2 | from rest_framework.response import Response 3 | from rest_framework.permissions import IsAuthenticated 4 | from login.models import Team, TeamMember 5 | from login.serializers import TeamSerializers 6 | import os 7 | from team_management.serializers import TeamManagementSerializers 8 | from rest_framework.decorators import action 9 | 10 | class TeamManagementViewSet(mixins.CreateModelMixin, 11 | mixins.UpdateModelMixin, 12 | viewsets.GenericViewSet): 13 | 14 | queryset = Team.objects.all() 15 | serializer_class = TeamSerializers 16 | permission_classes = [IsAuthenticated] 17 | lookup_field = "pk" 18 | 19 | def get_team_data_from_request(self, request): 20 | team_data = { 21 | 'name': request.data.get('name'), 22 | 'description': request.data.get('description', '-'), 23 | 'logo': request.data.get('logo'), 24 | } 25 | return team_data 26 | 27 | def create(self, request, *args, **kwargs): 28 | team_data = self.get_team_data_from_request(request) 29 | team_serializer = TeamSerializers(data=team_data) 30 | if team_serializer.is_valid(): 31 | new_team = Team.objects.create(**team_data) 32 | TeamMember.objects.create(team=new_team, user=request.user, verified=True) 33 | serialized_obj = TeamSerializers(new_team) 34 | return Response(data=serialized_obj.data, status=status.HTTP_201_CREATED) 35 | 36 | return Response(data={"error": "Please make sure your [team name] is exist!"}, status=status.HTTP_400_BAD_REQUEST) 37 | 38 | def update(self, request, *args, **kwargs): 39 | team = Team.objects.get(pk=kwargs['pk']) 40 | if (request.auth.team == team): 41 | if (request.data.get('name') is not None): 42 | return Response(data={"error": "Team name cannot be changed!"}, status=status.HTTP_400_BAD_REQUEST) 43 | 44 | # remove old image 45 | if request.data.get('logo') is not None : 46 | if (team.logo and os.path.exists(team.logo.path)): 47 | os.remove(team.logo.path) 48 | team.logo = request.data.get('logo') 49 | 50 | if (request.data.get('description') is not None): 51 | team.description = request.data.get('description') 52 | 53 | team.save() 54 | 55 | serialized_obj = TeamSerializers(team) 56 | return Response(data=serialized_obj.data, status=status.HTTP_200_OK) 57 | else: 58 | return Response(data={"error": "Team not found"}, status=status.HTTP_400_BAD_REQUEST) 59 | 60 | 61 | @action(detail=False,methods=["GET"]) 62 | def current(self, request): 63 | serializer = TeamManagementSerializers(request.auth.team) 64 | return Response(serializer.data) 65 | 66 | 67 | -------------------------------------------------------------------------------- /template/email/accept_invite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MonAPI Invitation to Team {{team_name}} 8 | 9 | 10 | 11 |
12 |
13 |

You are invited to the team!

14 |

15 | You have been invited to join Team {{team.name}}
16 | Please follow below link to accept the invitation and join the team. 17 |

18 | {{accept_url}} 19 |
20 | This link is valid for 1 week. 21 |

22 |
MonAPI Monitoring Platform
23 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /template/email/alerts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MonAPI Alerts 8 | 9 | 10 | 11 |
12 |
13 |

Alerts!

14 |

15 | Success rate monitor {{api_monitor_name}} is dropping below {{threshold_pct}}%
16 | Current success rate from {{start_time}} - {{end_time}} is {{success_rate}}%
17 | You can check the error logs in here. 18 |

19 |

MonAPI Monitoring Platform

20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /template/email/reset_password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MonAPI Reset Password Request 8 | 9 | 10 | 11 |
12 |
13 |

Password Reset Request

14 |

15 | We have received a password reset request for your email.
16 | Please follow below link to create new password for your account. 17 |

18 | {{reset_url}} 19 |
20 | This link is valid for 1 hours. 21 |

22 |
MonAPI Monitoring Platform
23 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /template/email/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #F5F5F5; 3 | color: #424242; 4 | } 5 | 6 | .main-content { 7 | background-color: white; 8 | height: max-content; 9 | padding: 60px; 10 | padding-top: 50px; 11 | padding-bottom: 50px; 12 | } 13 | 14 | .logo-head { 15 | width: 200px; 16 | display: block; 17 | margin-right: auto; 18 | } 19 | 20 | html { 21 | font-family: "Montserrat", sans-serif; 22 | font-size: 14px; 23 | } 24 | 25 | .hero-text { 26 | font-size: 23px; 27 | font-weight: 400; 28 | text-align: left; 29 | } 30 | 31 | .flex-row { 32 | display: flex; 33 | flex-direction: row; 34 | } 35 | 36 | .ammount, 37 | .bank-acc { 38 | border: 2px #eeeeee solid; 39 | border-radius: 15px; 40 | width: max-content; 41 | padding: 20px; 42 | margin: 25px; 43 | } 44 | 45 | .tixevent-big { 46 | color: #ff5722; 47 | font-weight: 700; 48 | font-size: 25px; 49 | } 50 | 51 | .timeout { 52 | border: 2px #eeeeee solid; 53 | border-radius: 15px; 54 | width: max-content; 55 | padding: 30px; 56 | margin: 25px; 57 | margin-left: auto; 58 | margin-right: auto; 59 | font-size: 20px; 60 | font-weight: 600; 61 | } 62 | 63 | td, 64 | th { 65 | padding: 15px; 66 | border: 2px #ffffff solid; 67 | 68 | } 69 | 70 | table { 71 | background-color: #F5F5F5; 72 | border: 2px #ffffff solid; 73 | border-collapse: collapse; 74 | margin-left: auto; 75 | margin-right: auto; 76 | } 77 | 78 | .footer-text { 79 | font-size: 10px; 80 | margin: 25px; 81 | } 82 | 83 | .buyer-information { 84 | margin-left: 25px; 85 | } 86 | 87 | .contact-footer { 88 | color: #ff5722; 89 | font-weight: 600; 90 | text-align: left; 91 | } 92 | 93 | .text { 94 | margin-top: 25px; 95 | margin-bottom: 25px; 96 | } 97 | 98 | .main-content-outer{ 99 | padding: 30px; 100 | } -------------------------------------------------------------------------------- /template/email/verify_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MonAPI User Verification 8 | 9 | 10 | 11 |
12 |
13 |

MonAPI User Verification

14 |

15 | To verify your registration on MonAPI, please follow the link below.
16 | If you did not register on MonAPI, you can ignore this email. 17 |

18 | {{verify_url}} 19 |
20 |

21 |

MonAPI Monitoring Platform

22 |
23 |
24 | 25 | 26 | --------------------------------------------------------------------------------